diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000..5dff59f --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,45 @@ +name: Go CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Check formatting + shell: bash + run: | + unformatted="$(gofmt -l .)" + if [[ -n "$unformatted" ]]; then + printf '%s\n' "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Build + run: go build ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64ad660 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Build output +/teams +/teams.exe +/dist/ diff --git a/README.md b/README.md index fb5234e..c32b6b1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,193 @@ -# microsoft-teams-cli -A command line interface for Microsoft Teams +# Microsoft Teams CLI + +A command line interface for Microsoft Teams. + +`teams` reads Teams data (teams, channels, messages and threads) through +Microsoft Graph. It is built to be called by **AI coding agents** as a +lightweight alternative to an MCP server: every command writes structured JSON +to stdout, errors are JSON on stderr, and exit codes are stable. + +```sh +teams search \ + --channel platform-engineering \ + --since 30d \ + --query "private endpoints" +``` + +## Install + +Requires Go 1.24+. + +```sh +go install github.com/glenthomas/microsoft-teams-cli/cmd/teams@latest +# or, from a clone: +go build -o teams ./cmd/teams +``` + +## Authenticate + +```sh +teams login +``` + +A browser window opens so you can sign in with your work or school account +(OAuth 2.0 authorization code flow with PKCE; the CLI listens on a temporary +`http://localhost` port for the redirect). If a browser cannot be opened the +sign-in URL is printed to stderr. On headless machines use: + +```sh +teams login --device-code +``` + +Tokens are cached (owner-only file permissions) in the CLI config directory +(`~/.config/teams-cli` on Linux, `~/Library/Application Support/teams-cli` on +macOS, `%AppData%\teams-cli` on Windows, or `$TEAMS_CLI_CONFIG_DIR`) and are +refreshed silently by later commands. `teams logout` removes them. + +### Permissions and app registration + +The CLI requests these delegated Microsoft Graph permissions: +`User.Read`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`, +`ChannelMessage.Read.All`, `ChannelMessage.Send`, `Chat.ReadBasic`, `Chat.Read`, +and `ChatMessage.Send` (plus `offline_access`). +`ChannelMessage.Read.All` requires **admin consent** in most tenants. +`ChannelMessage.Send` is used by `teams post`; admin consent is not generally +required, though tenant policies can restrict user consent. +The chat permissions are used by `teams chats`, `teams chat-messages`, and +`teams chat-post`. Their delegated Graph permissions do not generally require +admin consent, though tenant consent policies can still require approval. + +By default the public *Microsoft Graph Command Line Tools* application +(`14d82eec-204b-4c2f-b7e8-296a70dab67e`) and the `organizations` authority are +used. To use your own app registration (a public client with the +`http://localhost` redirect URI under "Mobile and desktop applications") or a +specific tenant: + +```sh +teams login --client-id --tenant contoso.onmicrosoft.com +``` + +The client ID and tenant used at login are remembered for later commands. +They can also be set with `TEAMS_CLI_CLIENT_ID` / `TEAMS_CLI_TENANT_ID`. + +If you already have a Graph access token (e.g. in CI), set +`TEAMS_CLI_ACCESS_TOKEN` and no login is needed. + +## Commands + +| Command | Description | +| --- | --- | +| `teams login [--device-code] [--login-hint user@x] [--timeout 5m]` | Sign in (opens a browser) | +| `teams logout` | Remove cached credentials | +| `teams whoami` | Show the signed-in user | +| `teams teams` | List teams you are a member of | +| `teams channels [--team T]` | List channels in a team, or in all your teams | +| `teams chats` | List your one-to-one and group chats (excludes meeting chats) | +| `teams messages --channel C [--team T] [--since 7d]` | List recent messages and replies, newest first | +| `teams search --channel C [--team T] --query Q [--since 30d]` | Search messages and replies, newest first | +| `teams thread --channel C [--team T] --id ID` | Show a thread (root + replies), oldest first | +| `teams post --channel C [--team T] --message TEXT [--reply-to ID]` | Post a channel message or reply to a thread | +| `teams chat-messages --chat ID` | List messages in a one-to-one or group chat, newest first | +| `teams chat-post --chat ID --message TEXT` | Send a message to a one-to-one or group chat | + +`--reply-to` takes the root message ID (the `threadId` field from `messages`, +`search`, or `thread` output). For example: + +```sh +teams post --channel platform-engineering --message "Deployment is complete" +teams post --channel platform-engineering --reply-to 1717171717171 --message "Acknowledged" +teams chats +teams chat-messages --chat '19:abc@thread.v2' +teams chat-post --chat '19:abc@thread.v2' --message "I will take a look" +``` + +Use the chat ID from `teams chats` with `chat-messages` and `chat-post`. Chat +history is limited to chats the signed-in user participates in. + +Common flags for `messages` and `search`: + +- `--channel/-c` channel name or ID (`19:...@thread.tacv2`). Names match + case-insensitively and ignore spaces/punctuation, so `platform-engineering` + matches *Platform Engineering*. If a name exists in several teams, add + `--team`. +- `--team/-t` team name or ID. Without `--channel`, every channel in the team + is scanned. +- `--since/-s`, `--until` relative (`90m`, `12h`, `30d`, `2w`, `3mo`, `1y`), + a date (`2026-09-01`) or an RFC 3339 timestamp. +- `--query/-q` (search) all words must appear (case-insensitive); use double + quotes for exact phrases: `--query '"private endpoint" dns'`. +- `--limit/-n` maximum results (default 50, `0` = unlimited). +- `--no-replies` only root messages. +- `--max-threads` maximum threads scanned per channel (default 1000). + +Global flags: `--format json|text` (default `json`), `--client-id`, `--tenant`. + +## Output + +`search` / `messages` return: + +```json +{ + "query": "private endpoints", + "terms": ["private", "endpoints"], + "since": "2026-08-31T15:00:00Z", + "channels": [ + {"teamId": "…", "teamName": "Platform", "channelId": "19:…@thread.tacv2", "channelName": "Platform Engineering"} + ], + "count": 1, + "truncated": false, + "messages": [ + { + "id": "1727000000000", + "type": "message", + "threadId": "1727000000000", + "teamId": "…", + "teamName": "Platform", + "channelId": "19:…@thread.tacv2", + "channelName": "Platform Engineering", + "author": "Alice Smith", + "authorId": "…", + "createdDateTime": "2026-09-27T10:12:00Z", + "text": "Should we use private endpoints for ACR?", + "webUrl": "https://teams.microsoft.com/l/message/…", + "replyCount": 2 + } + ] +} +``` + +`type` is `message` for a thread's root post or `reply`; pass `threadId` to +`teams thread --id` to read the full conversation. `truncated` is `true` when +`--limit` or `--max-threads` cut the results short (details in `warnings`). +Message bodies are converted from HTML to plain text. + +Errors are written to stderr: + +```json +{"error": {"code": "ambiguous", "message": "channel \"general\" is ambiguous; use --team to disambiguate. …", "details": {…}}} +``` + +| Exit code | `error.code` | Meaning | +| --- | --- | --- | +| 0 | | Success | +| 1 | `error`, `timeout` | Unexpected error, or `teams login` exceeded `--timeout` | +| 2 | `usage` | Invalid flags or arguments | +| 3 | `not_logged_in` | No cached sign-in or it expired — run `teams login` | +| 4 | `not_found`, `ambiguous` | Team/channel/message not found, or name matched several | +| 5 | `forbidden`, `throttled`, `graph_error` | Microsoft Graph API error | + +## How search works + +Microsoft Graph has no server-side text filter for channel messages, so the +CLI pages through the channel's threads (newest activity first, with replies +expanded) and filters them locally by time and text. Paging stops at the first +page with no activity after `--since`, so a tight `--since` keeps searches +fast. Throttled requests (HTTP 429/503) are retried honouring `Retry-After`. + +## Development + +```sh +go build ./... +go vet ./... +go test ./... +``` diff --git a/cmd/teams/main.go b/cmd/teams/main.go new file mode 100644 index 0000000..42ce2e8 --- /dev/null +++ b/cmd/teams/main.go @@ -0,0 +1,17 @@ +// Command teams is a command-line interface for Microsoft Teams. +package main + +import ( + "context" + "os" + "os/signal" + + "github.com/glenthomas/microsoft-teams-cli/internal/cli" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + code := cli.Main(ctx) + stop() + os.Exit(code) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5e82449 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/glenthomas/microsoft-teams-cli + +go 1.24.13 + +require ( + github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70a6b93 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 h1:0O6j18nQDoIff6/mtosT5LNU2WehEwp+LqfX6699oqY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1/go.mod h1:xdYAf5bjkOpsd3auA8riiJW4vneBubt9caavL7626jQ= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..971819b --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,184 @@ +// Package auth handles Microsoft Entra ID sign-in for the CLI using MSAL. +package auth + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/public" + "github.com/pkg/browser" + + "github.com/glenthomas/microsoft-teams-cli/internal/config" +) + +// Scopes are the delegated Microsoft Graph permissions requested by the CLI. +// MSAL automatically adds openid, profile and offline_access. +var Scopes = []string{ + "https://graph.microsoft.com/User.Read", + "https://graph.microsoft.com/Team.ReadBasic.All", + "https://graph.microsoft.com/Channel.ReadBasic.All", + "https://graph.microsoft.com/ChannelMessage.Read.All", + "https://graph.microsoft.com/ChannelMessage.Send", + "https://graph.microsoft.com/Chat.ReadBasic", + "https://graph.microsoft.com/Chat.Read", + "https://graph.microsoft.com/ChatMessage.Send", +} + +// ErrNotLoggedIn indicates there is no usable cached sign-in. +var ErrNotLoggedIn = errors.New("not logged in: run `teams login`") + +// Account describes the signed-in user. +type Account struct { + Username string `json:"username"` + HomeAccountID string `json:"homeAccountId"` + TenantID string `json:"tenantId,omitempty"` +} + +// Authenticator acquires Graph access tokens. +type Authenticator struct { + ClientID string + Tenant string + client public.Client +} + +// New creates an Authenticator whose token cache lives in configDir. +func New(clientID, tenant, configDir string) (*Authenticator, error) { + authority := "https://login.microsoftonline.com/" + tenant + c, err := public.New(clientID, + public.WithAuthority(authority), + public.WithCache(&fileCache{path: config.CachePath(configDir)}), + ) + if err != nil { + return nil, fmt.Errorf("initialising authentication: %w", err) + } + return &Authenticator{ClientID: clientID, Tenant: tenant, client: c}, nil +} + +// LoginOptions control the interactive sign-in flow. +type LoginOptions struct { + // DeviceCode uses the device code flow instead of opening a browser. + DeviceCode bool + // LoginHint pre-fills the username on the sign-in page. + LoginHint string + // Status receives human-readable progress messages (e.g. stderr). + Status io.Writer +} + +// Login signs the user in, by default by opening the system browser and +// listening on a localhost redirect (auth code flow with PKCE). +func (a *Authenticator) Login(ctx context.Context, opts LoginOptions) (Account, error) { + status := opts.Status + if status == nil { + status = io.Discard + } + var ( + res public.AuthResult + err error + ) + if opts.DeviceCode { + var dc public.DeviceCode + dc, err = a.client.AcquireTokenByDeviceCode(ctx, Scopes) + if err != nil { + return Account{}, fmt.Errorf("starting device code sign-in: %w", err) + } + fmt.Fprintln(status, dc.Result.Message) + res, err = dc.AuthenticationResult(ctx) + } else { + iopts := []public.AcquireInteractiveOption{ + public.WithOpenURL(func(u string) error { + fmt.Fprintln(status, "Opening your browser to sign in to Microsoft Teams...") + fmt.Fprintf(status, "If the browser does not open, visit this URL:\n\n%s\n\n", u) + if err := openBrowser(u); err != nil { + fmt.Fprintf(status, "Could not open a browser automatically (%v).\n", err) + } + return nil + }), + } + if opts.LoginHint != "" { + iopts = append(iopts, public.WithLoginHint(opts.LoginHint)) + } + res, err = a.client.AcquireTokenInteractive(ctx, Scopes, iopts...) + } + if err != nil { + return Account{}, fmt.Errorf("sign-in failed: %w", err) + } + + // Keep only the account that just signed in so later commands are + // unambiguous. + if accts, err := a.client.Accounts(ctx); err == nil { + for _, acct := range accts { + if acct.HomeAccountID != res.Account.HomeAccountID { + _ = a.client.RemoveAccount(ctx, acct) + } + } + } + return toAccount(res.Account), nil +} + +var openBrowser = func(u string) error { + browser.Stdout = io.Discard + browser.Stderr = io.Discard + return browser.OpenURL(u) +} + +// CurrentAccount returns the cached signed-in account. +func (a *Authenticator) CurrentAccount(ctx context.Context) (public.Account, error) { + accts, err := a.client.Accounts(ctx) + if err != nil { + return public.Account{}, fmt.Errorf("reading token cache: %w", err) + } + if len(accts) == 0 { + return public.Account{}, ErrNotLoggedIn + } + return accts[0], nil +} + +// Token returns a valid Graph access token, refreshing it silently if needed. +func (a *Authenticator) Token(ctx context.Context) (string, error) { + acct, err := a.CurrentAccount(ctx) + if err != nil { + return "", err + } + res, err := a.client.AcquireTokenSilent(ctx, Scopes, public.WithSilentAccount(acct)) + if err != nil { + return "", fmt.Errorf("%w (session expired or consent missing: %v)", ErrNotLoggedIn, err) + } + return res.AccessToken, nil +} + +// Logout removes all cached accounts. +func (a *Authenticator) Logout(ctx context.Context) ([]Account, error) { + accts, err := a.client.Accounts(ctx) + if err != nil { + return nil, err + } + var removed []Account + for _, acct := range accts { + if err := a.client.RemoveAccount(ctx, acct); err != nil { + return removed, err + } + removed = append(removed, toAccount(acct)) + } + return removed, nil +} + +func toAccount(a public.Account) Account { + return Account{Username: a.PreferredUsername, HomeAccountID: a.HomeAccountID, TenantID: a.Realm} +} + +// StaticToken is a TokenSource that returns a fixed access token, e.g. from +// the TEAMS_CLI_ACCESS_TOKEN environment variable. +type StaticToken string + +// Token implements the graph TokenSource interface. +func (s StaticToken) Token(context.Context) (string, error) { return string(s), nil } + +// EnvToken returns the access token supplied via environment, if any. +func EnvToken() (StaticToken, bool) { + t := strings.TrimSpace(os.Getenv(config.EnvAccessToken)) + return StaticToken(t), t != "" +} diff --git a/internal/auth/cache.go b/internal/auth/cache.go new file mode 100644 index 0000000..3c2e04c --- /dev/null +++ b/internal/auth/cache.go @@ -0,0 +1,41 @@ +package auth + +import ( + "context" + "errors" + "os" + + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/cache" + + "github.com/glenthomas/microsoft-teams-cli/internal/config" +) + +// fileCache persists the MSAL token cache to a single owner-only file. +type fileCache struct { + path string +} + +func (f *fileCache) Replace(ctx context.Context, c cache.Unmarshaler, _ cache.ReplaceHints) error { + if err := ctx.Err(); err != nil { + return err + } + b, err := os.ReadFile(f.path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + return c.Unmarshal(b) +} + +func (f *fileCache) Export(ctx context.Context, c cache.Marshaler, _ cache.ExportHints) error { + if err := ctx.Err(); err != nil { + return err + } + b, err := c.Marshal() + if err != nil { + return err + } + return config.WriteFileAtomic(f.path, b) +} diff --git a/internal/cli/auth_cmds.go b/internal/cli/auth_cmds.go new file mode 100644 index 0000000..742a84e --- /dev/null +++ b/internal/cli/auth_cmds.go @@ -0,0 +1,106 @@ +package cli + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + + "github.com/glenthomas/microsoft-teams-cli/internal/auth" + "github.com/glenthomas/microsoft-teams-cli/internal/config" +) + +func (a *App) loginCmd() *cobra.Command { + var ( + deviceCode bool + loginHint string + timeout time.Duration + ) + cmd := &cobra.Command{ + Use: "login", + Short: "Sign in to Microsoft Teams (opens a browser window)", + Long: `Sign in with your work or school account. By default a browser window is +opened and the CLI listens on a localhost redirect to receive the result +(OAuth 2.0 authorization code flow with PKCE). Use --device-code on machines +without a browser. + +Tokens are cached in the CLI config directory (owner-only permissions) and +refreshed automatically by later commands.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if timeout <= 0 { + return newUsageError(fmt.Errorf("--timeout must be positive")) + } + au, dir, err := a.authenticator() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + acct, err := au.Login(ctx, auth.LoginOptions{DeviceCode: deviceCode, LoginHint: loginHint, Status: a.Stderr}) + if err != nil { + return err + } + if err := config.Save(dir, config.Settings{ClientID: au.ClientID, Tenant: au.Tenant}); err != nil { + return fmt.Errorf("saving settings: %w", err) + } + return a.emit(map[string]any{"status": "logged_in", "account": acct}, func(w io.Writer) { + fmt.Fprintf(w, "Logged in as %s\n", acct.Username) + }) + }, + } + cmd.Flags().BoolVar(&deviceCode, "device-code", false, "use the device code flow instead of opening a browser") + cmd.Flags().StringVar(&loginHint, "login-hint", "", "username to pre-fill on the sign-in page") + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Minute, "maximum time to wait for sign-in to complete") + return cmd +} + +func (a *App) logoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove cached credentials", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + au, dir, err := a.authenticator() + if err != nil { + return err + } + removed, err := au.Logout(cmd.Context()) + if err != nil { + return err + } + if err := config.Remove(dir); err != nil { + return err + } + if removed == nil { + removed = []auth.Account{} + } + return a.emit(map[string]any{"status": "logged_out", "accounts": removed}, func(w io.Writer) { + fmt.Fprintln(w, "Logged out") + }) + }, + } +} + +func (a *App) whoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show the signed-in user", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + g, err := a.NewGraph(cmd.Context()) + if err != nil { + return err + } + me, err := g.Me(cmd.Context()) + if err != nil { + return err + } + return a.emit(me, func(w io.Writer) { + fmt.Fprintf(w, "%s <%s>\n", me.DisplayName, me.UserPrincipalName) + }) + }, + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..ad9a649 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,289 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph" + "github.com/glenthomas/microsoft-teams-cli/internal/graph/graphtest" +) + +var now = time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + +func run(t *testing.T, srv *graphtest.Server, args ...string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + app := &App{ + Stdout: &stdout, Stderr: &stderr, + Now: func() time.Time { return now }, + NewGraph: func(context.Context) (*graph.Client, error) { return srv.Client(), nil }, + } + code := app.Run(context.Background(), args) + return code, stdout.String(), stderr.String() +} + +func seeded(t *testing.T) *graphtest.Server { + srv := graphtest.New(t) + srv.Seed(now) + return srv +} + +func TestSearchJSON(t *testing.T) { + srv := seeded(t) + code, out, errOut := run(t, srv, "search", "--channel", "platform-engineering", "--since", "30d", "--query", "private endpoints") + if code != ExitOK { + t.Fatalf("exit %d: %s", code, errOut) + } + var res struct { + Terms []string `json:"terms"` + Since time.Time + Channels []struct{ ChannelName string } + Count int + Messages []struct { + ID, Type, ThreadID, Author, Text, ChannelName, TeamName string + } + } + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if res.Count != 3 || len(res.Messages) != 3 || res.Messages[0].ID != "r1" || res.Messages[2].ID != "r3" { + t.Fatalf("unexpected result: %s", out) + } + if !res.Since.Equal(now.AddDate(0, 0, -30)) || len(res.Terms) != 2 || res.Channels[0].ChannelName != "Platform Engineering" { + t.Fatalf("unexpected metadata: %s", out) + } + if m := res.Messages[1]; m.Type != "message" || m.Author != "Alice" || m.TeamName != "Platform" || !strings.Contains(m.Text, "Private Endpoints") { + t.Fatalf("unexpected message: %+v", m) + } +} + +func TestSearchLimitAndText(t *testing.T) { + srv := seeded(t) + code, out, _ := run(t, srv, "search", "-c", "Platform Engineering", "-q", "private endpoints", "-n", "1", "--format", "text") + if code != ExitOK || !strings.Contains(out, "Bob in Platform/Platform Engineering (reply)") || strings.Contains(out, "Alice") { + t.Fatalf("exit %d, output:\n%s", code, out) + } + + code, out, _ = run(t, srv, "search", "-c", "Platform Engineering", "-q", "private endpoints", "-n", "1") + if code != ExitOK || !strings.Contains(out, `"truncated": true`) { + t.Fatalf("expected truncated output, got %s", out) + } +} + +func TestMessagesAcrossTeamSkipsForbiddenChannels(t *testing.T) { + srv := seeded(t) + srv.Forbidden["19:gen1@thread.tacv2"] = true + code, out, errOut := run(t, srv, "messages", "--team", "Platform", "--since", "7d") + if code != ExitOK { + t.Fatalf("exit %d: %s", code, errOut) + } + if !strings.Contains(out, "skipped Platform/General") || !strings.Contains(out, `"id": "m1"`) || strings.Contains(out, `"id": "m2"`) { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestThreadCommand(t *testing.T) { + srv := seeded(t) + code, out, errOut := run(t, srv, "thread", "--channel", "platform-engineering", "--id", "m1") + if code != ExitOK || !strings.Contains(out, `"count": 3`) || !strings.Contains(out, `"threadId": "m1"`) { + t.Fatalf("exit %d: %s %s", code, out, errOut) + } +} + +func TestPostCommandAndReply(t *testing.T) { + srv := seeded(t) + code, out, errOut := run(t, srv, "post", "--channel", "platform-engineering", "--message", "Deploy complete") + if code != ExitOK { + t.Fatalf("post exit %d: %s", code, errOut) + } + var posted struct { + Status string `json:"status"` + Message struct { + ID string `json:"id"` + Type string `json:"type"` + ThreadID string `json:"threadId"` + Text string `json:"text"` + } `json:"message"` + } + if err := json.Unmarshal([]byte(out), &posted); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if posted.Status != "posted" || posted.Message.ID == "" || posted.Message.Type != "message" || posted.Message.ThreadID != posted.Message.ID || posted.Message.Text != "Deploy complete" { + t.Fatalf("unexpected post response: %s", out) + } + + code, out, errOut = run(t, srv, "post", "--channel", "platform-engineering", "--reply-to", "m1", "--message", "Acknowledged") + if code != ExitOK || !strings.Contains(out, `"type": "reply"`) || !strings.Contains(out, `"threadId": "m1"`) { + t.Fatalf("reply exit %d: %s %s", code, out, errOut) + } + code, out, errOut = run(t, srv, "thread", "--channel", "platform-engineering", "--id", "m1") + if code != ExitOK || !strings.Contains(out, "Acknowledged") { + t.Fatalf("thread after reply exit %d: %s %s", code, out, errOut) + } +} + +func TestPostRequiresChannelAndMessage(t *testing.T) { + srv := seeded(t) + for _, args := range [][]string{ + {"post", "--message", "hello"}, + {"post", "--channel", "platform-engineering", "--message", " "}, + } { + code, out, errOut := run(t, srv, args...) + if code != ExitUsage || out != "" || !strings.Contains(errOut, "--channel and non-empty --message are required") { + t.Errorf("%v: exit %d stdout=%q stderr=%s", args, code, out, errOut) + } + } +} + +func TestPrivateChatCommands(t *testing.T) { + srv := seeded(t) + srv.Chats = []graph.Chat{ + {ID: "one-to-one", ChatType: "oneOnOne"}, + {ID: "group-chat", ChatType: "group", Topic: "Release planning"}, + {ID: "meeting-chat", ChatType: "meeting"}, + } + srv.PageSize = 1 + srv.ChatMessages["one-to-one"] = []graph.ChatMessage{ + {ID: "chat-m2", ChatID: "one-to-one", MessageType: "message", CreatedDateTime: now.Add(-time.Hour), From: &graph.IdentitySet{User: &graph.Identity{ID: "u2", DisplayName: "Alice"}}, Body: graph.ItemBody{ContentType: "html", Content: "

Second

"}}, + {ID: "chat-m1", ChatID: "one-to-one", MessageType: "message", CreatedDateTime: now.Add(-2 * time.Hour), Body: graph.ItemBody{ContentType: "text", Content: "First"}}, + } + + code, out, errOut := run(t, srv, "chats") + if code != ExitOK || strings.Contains(errOut, "error") { + t.Fatalf("chats exit %d: %s", code, errOut) + } + var chatResult struct { + Count int `json:"count"` + Chats []graph.Chat `json:"chats"` + } + if err := json.Unmarshal([]byte(out), &chatResult); err != nil { + t.Fatalf("invalid chat JSON: %v\n%s", err, out) + } + if chatResult.Count != 2 || len(chatResult.Chats) != 2 || chatResult.Chats[1].ID != "group-chat" { + t.Fatalf("unexpected chat list: %s", out) + } + + code, out, errOut = run(t, srv, "chat-messages", "--chat", "one-to-one") + if code != ExitOK { + t.Fatalf("chat-messages exit %d: %s", code, errOut) + } + var messageResult struct { + Count int `json:"count"` + Messages []struct { + ID, Author, Text string + } `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &messageResult); err != nil { + t.Fatalf("invalid chat message JSON: %v\n%s", err, out) + } + if messageResult.Count != 2 || messageResult.Messages[0].ID != "chat-m2" || messageResult.Messages[0].Author != "Alice" || messageResult.Messages[0].Text != "Second" { + t.Fatalf("unexpected chat messages: %s", out) + } + requestsBeforePost := len(srv.Requests()) + + code, out, errOut = run(t, srv, "chat-post", "--chat", "one-to-one", "--message", "Replying in chat") + if code != ExitOK { + t.Fatalf("chat-post exit %d: %s", code, errOut) + } + if !strings.Contains(out, `"status": "sent"`) || !strings.Contains(out, `"text": "Replying in chat"`) { + t.Fatalf("unexpected chat-post response: %s", out) + } + requests := srv.Requests() + if got := requests[len(requests)-1]; !strings.Contains(got, "/chats/one-to-one/messages") || len(requests) != requestsBeforePost+1 { + t.Fatalf("unexpected chat send requests: %v", requests) + } +} + +func TestChatCommandsRequireArguments(t *testing.T) { + srv := seeded(t) + for _, args := range [][]string{ + {"chat-messages"}, + {"chat-post", "--chat", "some-chat", "--message", " "}, + } { + code, out, errOut := run(t, srv, args...) + if code != ExitUsage || out != "" || errorCode(t, errOut) != "usage" { + t.Errorf("%v: exit %d stdout=%q stderr=%s", args, code, out, errOut) + } + } +} + +func TestListCommands(t *testing.T) { + srv := seeded(t) + if code, out, _ := run(t, srv, "teams"); code != ExitOK || !strings.Contains(out, `"count": 2`) { + t.Fatalf("teams: %d %s", code, out) + } + if code, out, _ := run(t, srv, "channels", "--team", "Platform"); code != ExitOK || !strings.Contains(out, "19:pe@thread.tacv2") || strings.Contains(out, "gen2") { + t.Fatalf("channels: %d %s", code, out) + } + if code, out, _ := run(t, srv, "whoami"); code != ExitOK || !strings.Contains(out, "test@example.com") { + t.Fatalf("whoami: %d %s", code, out) + } +} + +func errorCode(t *testing.T, stderr string) string { + t.Helper() + var e struct{ Error ErrorInfo } + if err := json.Unmarshal([]byte(stderr), &e); err != nil { + t.Fatalf("stderr is not JSON: %v\n%s", err, stderr) + } + return e.Error.Code +} + +func TestErrors(t *testing.T) { + srv := seeded(t) + cases := []struct { + args []string + exit int + errCode string + errMatch string + }{ + {[]string{"search", "--channel", "general", "--query", "x"}, ExitNotFound, "ambiguous", "--team"}, + {[]string{"search", "--channel", "nope", "--query", "x"}, ExitNotFound, "not_found", "Platform Engineering"}, + {[]string{"search", "--channel", "general"}, ExitUsage, "usage", "--query is required"}, + {[]string{"search", "--query", "x"}, ExitUsage, "usage", "--channel or --team"}, + {[]string{"messages", "-c", "general", "-t", "data", "--since", "soon"}, ExitUsage, "usage", "invalid since"}, + {[]string{"messages", "-c", "general", "-t", "data", "--since", "1d", "--until", "2d"}, ExitUsage, "usage", "--until must be after"}, + {[]string{"teams", "--format", "xml"}, ExitUsage, "usage", "invalid --format"}, + {[]string{"bogus"}, ExitUsage, "usage", "unknown command"}, + {[]string{"login", "--timeout", "0s"}, ExitUsage, "usage", "--timeout must be positive"}, + {[]string{"thread", "--channel", "platform-engineering", "--id", "missing"}, ExitNotFound, "not_found", "message not found"}, + } + for _, c := range cases { + code, out, errOut := run(t, srv, c.args...) + if code != c.exit || out != "" || errorCode(t, errOut) != c.errCode || !strings.Contains(errOut, c.errMatch) { + t.Errorf("%v: exit=%d stdout=%q stderr=%s", c.args, code, out, errOut) + } + } +} + +func TestUnauthorizedMapsToAuthExitCode(t *testing.T) { + srv := seeded(t) + var stdout, stderr bytes.Buffer + app := &App{ + Stdout: &stdout, Stderr: &stderr, + NewGraph: func(context.Context) (*graph.Client, error) { + c := srv.Client() + c.Tokens = badToken{} + return c, nil + }, + } + if code := app.Run(context.Background(), []string{"whoami"}); code != ExitAuth || errorCode(t, stderr.String()) != "not_logged_in" { + t.Fatalf("exit %d: %s", code, stderr.String()) + } +} + +type badToken struct{} + +func (badToken) Token(context.Context) (string, error) { return "expired", nil } + +func TestClassifyTimeout(t *testing.T) { + info, code := classify(fmt.Errorf("sign-in failed: %w", context.DeadlineExceeded)) + if info.Code != "timeout" || code != ExitError { + t.Fatalf("got %s/%d", info.Code, code) + } +} diff --git a/internal/cli/data_cmds.go b/internal/cli/data_cmds.go new file mode 100644 index 0000000..a6b2768 --- /dev/null +++ b/internal/cli/data_cmds.go @@ -0,0 +1,410 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph" + "github.com/glenthomas/microsoft-teams-cli/internal/query" + "github.com/glenthomas/microsoft-teams-cli/internal/teams" +) + +func (a *App) teamsCmd() *cobra.Command { + return &cobra.Command{ + Use: "teams", + Short: "List the teams you are a member of", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + svc, err := a.service(cmd.Context()) + if err != nil { + return err + } + ts, err := svc.ResolveTeams(cmd.Context(), "") + if err != nil { + return err + } + if ts == nil { + ts = []graph.Team{} + } + return a.emit(map[string]any{"count": len(ts), "teams": ts}, func(w io.Writer) { + for _, t := range ts { + fmt.Fprintf(w, "%s\t%s\n", t.ID, t.DisplayName) + } + }) + }, + } +} + +func (a *App) channelsCmd() *cobra.Command { + var team string + cmd := &cobra.Command{ + Use: "channels", + Short: "List channels (in one team, or across all your teams)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + svc, err := a.service(cmd.Context()) + if err != nil { + return err + } + chans, err := svc.ListChannels(cmd.Context(), team) + if err != nil { + return err + } + if chans == nil { + chans = []teams.ChannelRef{} + } + return a.emit(map[string]any{"count": len(chans), "channels": chans}, func(w io.Writer) { + for _, c := range chans { + fmt.Fprintf(w, "%s\t%s\t%s\n", c.TeamName, c.ChannelName, c.ChannelID) + } + }) + }, + } + cmd.Flags().StringVarP(&team, "team", "t", "", "team name or ID (default: all joined teams)") + return cmd +} + +func (a *App) chatsCmd() *cobra.Command { + return &cobra.Command{ + Use: "chats", + Short: "List your one-to-one and group chats", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + svc, err := a.service(cmd.Context()) + if err != nil { + return err + } + chats, err := svc.ListPrivateChats(cmd.Context()) + if err != nil { + return err + } + if chats == nil { + chats = []graph.Chat{} + } + return a.emit(map[string]any{"count": len(chats), "chats": chats}, func(w io.Writer) { + for _, chat := range chats { + fmt.Fprintf(w, "%s\t%s\t%s\n", chat.ChatType, chat.Topic, chat.ID) + } + }) + }, + } +} + +func (a *App) chatMessagesCmd() *cobra.Command { + var chatID string + cmd := &cobra.Command{ + Use: "chat-messages", + Short: "List messages in a one-to-one or group chat (newest first)", + Example: ` teams chat-messages --chat 19:abc@thread.v2 + teams chat-messages --chat 19:abc@thread.v2 --format text`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(chatID) == "" { + return newUsageError(fmt.Errorf("--chat is required")) + } + svc, err := a.service(cmd.Context()) + if err != nil { + return err + } + messages, err := svc.ChatMessages(cmd.Context(), chatID) + if err != nil { + return err + } + if messages == nil { + messages = []teams.ChatMessage{} + } + return a.emit(map[string]any{"chatId": chatID, "count": len(messages), "messages": messages}, func(w io.Writer) { + for i, message := range messages { + if i > 0 { + fmt.Fprintln(w) + } + fmt.Fprintf(w, "[%s] %s\n%s\n", message.CreatedDateTime.Local().Format("2006-01-02 15:04"), message.Author, message.Text) + if message.WebURL != "" { + fmt.Fprintln(w, message.WebURL) + } + } + }) + }, + } + cmd.Flags().StringVarP(&chatID, "chat", "c", "", "chat ID (from teams chats; required)") + return cmd +} + +func (a *App) chatPostCmd() *cobra.Command { + var chatID, message string + cmd := &cobra.Command{ + Use: "chat-post", + Short: "Send a message to a one-to-one or group chat", + Example: ` teams chat-post --chat 19:abc@thread.v2 --message "I will take a look" + teams chat-post -c 19:abc@thread.v2 -m "Thanks"`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(message) == "" { + return newUsageError(fmt.Errorf("--chat and non-empty --message are required")) + } + svc, err := a.service(cmd.Context()) + if err != nil { + return err + } + created, err := svc.PostChatMessage(cmd.Context(), chatID, message) + if err != nil { + return err + } + return a.emit(map[string]any{"status": "sent", "chatId": chatID, "message": created}, func(w io.Writer) { + fmt.Fprintf(w, "Sent message %s to chat %s\n", created.ID, chatID) + if created.WebURL != "" { + fmt.Fprintln(w, created.WebURL) + } + }) + }, + } + cmd.Flags().StringVarP(&chatID, "chat", "c", "", "chat ID (from teams chats; required)") + cmd.Flags().StringVarP(&message, "message", "m", "", "message text to send (required)") + return cmd +} + +// messageFlags are shared by the messages and search commands. +type messageFlags struct { + team, channel, since, until, query string + limit, maxThreads int + noReplies bool +} + +func (f *messageFlags) register(cmd *cobra.Command) { + fl := cmd.Flags() + fl.StringVarP(&f.channel, "channel", "c", "", "channel name or ID; names match case-insensitively, ignoring spaces/punctuation") + fl.StringVarP(&f.team, "team", "t", "", "team name or ID (optional; narrows channel lookup, or searches all its channels if --channel is omitted)") + fl.StringVarP(&f.since, "since", "s", "", "only messages created after this time: relative (30d, 12h, 2w, 3mo) or date/RFC 3339 timestamp") + fl.StringVar(&f.until, "until", "", "only messages created before this time (same formats as --since)") + fl.IntVarP(&f.limit, "limit", "n", 50, "maximum number of messages to return (0 = no limit)") + fl.IntVar(&f.maxThreads, "max-threads", 1000, "maximum threads to scan per channel (0 = no limit)") + fl.BoolVar(&f.noReplies, "no-replies", false, "only include root messages, not thread replies") +} + +// messagesResult is the JSON document returned by messages and search. +type messagesResult struct { + Query string `json:"query,omitempty"` + Terms []string `json:"terms,omitempty"` + Since *time.Time `json:"since,omitempty"` + Until *time.Time `json:"until,omitempty"` + Channels []teams.ChannelRef `json:"channels"` + Count int `json:"count"` + Truncated bool `json:"truncated"` + Warnings []string `json:"warnings,omitempty"` + Messages []teams.Message `json:"messages"` +} + +func (a *App) runMessages(ctx context.Context, f *messageFlags) error { + if f.channel == "" && f.team == "" { + return newUsageError(fmt.Errorf("--channel or --team is required")) + } + if f.limit < 0 || f.maxThreads < 0 { + return newUsageError(fmt.Errorf("--limit and --max-threads must not be negative")) + } + now := a.Now().UTC().Truncate(time.Second) + since, err := query.ParseSince(f.since, now) + if err != nil { + return newUsageError(err) + } + until, err := query.ParseSince(f.until, now) + if err != nil { + return newUsageError(errors.New(strings.Replace(err.Error(), "since", "until", 1))) + } + if !since.IsZero() && !until.IsZero() && !until.After(since) { + return newUsageError(fmt.Errorf("--until must be after --since")) + } + + svc, err := a.service(ctx) + if err != nil { + return err + } + chans, err := svc.ResolveChannels(ctx, f.team, f.channel) + if err != nil { + return err + } + + matcher := query.NewMatcher(f.query) + opts := teams.CollectOptions{ + Since: since, Until: until, Matcher: matcher, + IncludeReplies: !f.noReplies, MaxThreads: f.maxThreads, + } + res := messagesResult{Query: f.query, Terms: matcher.Terms(), Channels: chans, Messages: []teams.Message{}} + if !since.IsZero() { + res.Since = &since + } + if !until.IsZero() { + res.Until = &until + } + for _, ch := range chans { + cr, err := svc.Collect(ctx, ch, opts) + if err != nil { + // When scanning a whole team, skip channels the user cannot read. + if f.channel == "" && (graph.IsStatus(err, 403) || graph.IsStatus(err, 404)) { + res.Warnings = append(res.Warnings, fmt.Sprintf("skipped %s/%s: %v", ch.TeamName, ch.ChannelName, err)) + continue + } + return err + } + if cr.Truncated { + res.Truncated = true + res.Warnings = append(res.Warnings, fmt.Sprintf("%s/%s: stopped after scanning %d threads (--max-threads); older messages were not searched", ch.TeamName, ch.ChannelName, cr.ThreadsScanned)) + } + res.Messages = append(res.Messages, cr.Messages...) + } + teams.SortNewestFirst(res.Messages) + if f.limit > 0 && len(res.Messages) > f.limit { + res.Messages = res.Messages[:f.limit] + res.Truncated = true + } + res.Count = len(res.Messages) + return a.emit(res, func(w io.Writer) { writeMessagesText(w, res.Messages, res.Warnings) }) +} + +func (a *App) messagesCmd() *cobra.Command { + f := &messageFlags{} + cmd := &cobra.Command{ + Use: "messages", + Short: "List recent messages in a channel (newest first)", + Example: ` teams messages --channel platform-engineering --since 7d + teams messages --team Platform --since 24h --no-replies`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return a.runMessages(cmd.Context(), f) }, + } + f.register(cmd) + return cmd +} + +func (a *App) searchCmd() *cobra.Command { + f := &messageFlags{} + cmd := &cobra.Command{ + Use: "search", + Short: "Search channel messages and replies for text (newest first)", + Long: `Search messages and thread replies in a channel (or every channel in a team). + +The query is matched case-insensitively against the message subject and plain +text. All words must be present (AND); wrap words in double quotes to match an +exact phrase, e.g. --query '"private endpoint" dns'.`, + Example: ` teams search --channel platform-engineering --since 30d --query "private endpoints" + teams search --team Platform --since 2w --query '"terraform plan" error'`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(f.query) == "" { + return newUsageError(fmt.Errorf("--query is required")) + } + return a.runMessages(cmd.Context(), f) + }, + } + f.register(cmd) + cmd.Flags().StringVarP(&f.query, "query", "q", "", "text to search for (required)") + return cmd +} + +func (a *App) threadCmd() *cobra.Command { + var team, channel, id string + cmd := &cobra.Command{ + Use: "thread", + Short: "Show a message thread (root message and all replies, oldest first)", + Example: ` teams thread --channel platform-engineering --id 1717171717171`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if channel == "" || id == "" { + return newUsageError(fmt.Errorf("--channel and --id are required")) + } + svc, err := a.service(cmd.Context()) + if err != nil { + return err + } + chans, err := svc.ResolveChannels(cmd.Context(), team, channel) + if err != nil { + return err + } + ch := chans[0] + msgs, err := svc.Thread(cmd.Context(), ch, id) + if err != nil { + return err + } + if msgs == nil { + msgs = []teams.Message{} + } + threadID := id + if len(msgs) > 0 { + threadID = msgs[0].ThreadID + } + out := map[string]any{"channel": ch, "threadId": threadID, "count": len(msgs), "messages": msgs} + return a.emit(out, func(w io.Writer) { writeMessagesText(w, msgs, nil) }) + }, + } + cmd.Flags().StringVarP(&channel, "channel", "c", "", "channel name or ID (required)") + cmd.Flags().StringVarP(&team, "team", "t", "", "team name or ID (optional)") + cmd.Flags().StringVar(&id, "id", "", "message ID of the thread's root message (the threadId field from search/messages output)") + return cmd +} + +func (a *App) postCmd() *cobra.Command { + var team, channel, message, replyTo string + cmd := &cobra.Command{ + Use: "post", + Short: "Post a message or reply in a channel", + Example: ` teams post --channel platform-engineering --message "Deployment is complete" + teams post --channel platform-engineering --reply-to 1717171717171 --message "Thanks for the update"`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if channel == "" || strings.TrimSpace(message) == "" { + return newUsageError(fmt.Errorf("--channel and non-empty --message are required")) + } + svc, err := a.service(cmd.Context()) + if err != nil { + return err + } + chans, err := svc.ResolveChannels(cmd.Context(), team, channel) + if err != nil { + return err + } + created, err := svc.PostMessage(cmd.Context(), chans[0], message, replyTo) + if err != nil { + return err + } + out := map[string]any{"status": "posted", "channel": chans[0], "message": created} + return a.emit(out, func(w io.Writer) { + fmt.Fprintf(w, "Posted message %s in %s/%s\n", created.ID, chans[0].TeamName, chans[0].ChannelName) + if created.WebURL != "" { + fmt.Fprintln(w, created.WebURL) + } + }) + }, + } + cmd.Flags().StringVarP(&channel, "channel", "c", "", "channel name or ID (required)") + cmd.Flags().StringVarP(&team, "team", "t", "", "team name or ID (optional; disambiguates channel names)") + cmd.Flags().StringVarP(&message, "message", "m", "", "message text to post (required)") + cmd.Flags().StringVar(&replyTo, "reply-to", "", "root message ID to reply to (default: post a new thread)") + return cmd +} + +func writeMessagesText(w io.Writer, msgs []teams.Message, warnings []string) { + for _, warn := range warnings { + fmt.Fprintf(w, "warning: %s\n", warn) + } + for i, m := range msgs { + if i > 0 { + fmt.Fprintln(w) + } + kind := "" + if m.Type == "reply" { + kind = " (reply)" + } + fmt.Fprintf(w, "[%s] %s in %s/%s%s\n", m.CreatedDateTime.Local().Format("2006-01-02 15:04"), m.Author, m.TeamName, m.ChannelName, kind) + if m.Subject != "" { + fmt.Fprintf(w, "Subject: %s\n", m.Subject) + } + fmt.Fprintln(w, m.Text) + if m.WebURL != "" { + fmt.Fprintln(w, m.WebURL) + } + } +} diff --git a/internal/cli/errors.go b/internal/cli/errors.go new file mode 100644 index 0000000..486c0dc --- /dev/null +++ b/internal/cli/errors.go @@ -0,0 +1,100 @@ +package cli + +import ( + "context" + "errors" + "strings" + + "github.com/glenthomas/microsoft-teams-cli/internal/auth" + "github.com/glenthomas/microsoft-teams-cli/internal/graph" + "github.com/glenthomas/microsoft-teams-cli/internal/teams" +) + +// Exit codes are part of the CLI contract so agents can branch on them. +const ( + ExitOK = 0 + ExitError = 1 + ExitUsage = 2 + ExitAuth = 3 + ExitNotFound = 4 + ExitGraphAPI = 5 + ExitInterrupted = 130 +) + +// ErrorInfo is the JSON payload written to stderr on failure. +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +type usageError struct{ error } + +func newUsageError(err error) error { return usageError{err} } + +// classify maps an error to a stable error code and process exit code. +func classify(err error) (ErrorInfo, int) { + info := ErrorInfo{Code: "error", Message: err.Error()} + var ( + ue usageError + apiErr *graph.APIError + notFound *teams.NotFoundError + amb *teams.AmbiguousError + ) + switch { + case errors.As(err, &ue): + info.Code = "usage" + return info, ExitUsage + case errors.Is(err, context.Canceled): + info.Code = "interrupted" + return info, ExitInterrupted + case errors.Is(err, context.DeadlineExceeded): + info.Code = "timeout" + return info, ExitError + case errors.Is(err, auth.ErrNotLoggedIn): + info.Code = "not_logged_in" + return info, ExitAuth + case errors.As(err, ¬Found): + info.Code = "not_found" + info.Details = notFound + return info, ExitNotFound + case errors.As(err, &amb): + info.Code = "ambiguous" + info.Details = amb + return info, ExitNotFound + case errors.As(err, &apiErr): + info.Details = apiErr + switch apiErr.StatusCode { + case 401: + info.Code = "not_logged_in" + info.Message += " (run `teams login`)" + return info, ExitAuth + case 403: + info.Code = "forbidden" + info.Message += " (the signed-in user or app may lack the required Graph permission/admin consent)" + case 404: + info.Code = "not_found" + return info, ExitNotFound + case 429: + info.Code = "throttled" + default: + info.Code = "graph_error" + } + return info, ExitGraphAPI + } + if isCobraUsageError(err) { + info.Code = "usage" + return info, ExitUsage + } + return info, ExitError +} + +func isCobraUsageError(err error) bool { + msg := err.Error() + for _, p := range []string{"unknown command", "unknown flag", "unknown shorthand flag", "required flag", "invalid argument", "accepts ", "flag needs an argument"} { + if strings.HasPrefix(msg, p) { + return true + } + } + return false +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..9f426e7 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,177 @@ +// Package cli defines the `teams` command-line interface. +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/glenthomas/microsoft-teams-cli/internal/auth" + "github.com/glenthomas/microsoft-teams-cli/internal/config" + "github.com/glenthomas/microsoft-teams-cli/internal/graph" + "github.com/glenthomas/microsoft-teams-cli/internal/teams" +) + +// Version is set at build time via -ldflags "-X .../internal/cli.Version=...". +var Version = "dev" + +// App holds CLI state shared by commands. +type App struct { + Stdout io.Writer + Stderr io.Writer + Now func() time.Time + // NewGraph builds the Graph client; overridable in tests. + NewGraph func(ctx context.Context) (*graph.Client, error) + + format string + clientID string + tenant string +} + +// Main runs the CLI with os.Args and returns the process exit code. +func Main(ctx context.Context) int { + app := &App{Stdout: os.Stdout, Stderr: os.Stderr, Now: time.Now} + return app.Run(ctx, os.Args[1:]) +} + +// Run executes the CLI with args and returns the process exit code. +func (a *App) Run(ctx context.Context, args []string) int { + if a.Now == nil { + a.Now = time.Now + } + if a.NewGraph == nil { + a.NewGraph = a.defaultGraph + } + root := a.rootCmd() + root.SetArgs(args) + root.SetOut(a.Stdout) + root.SetErr(a.Stderr) + err := root.ExecuteContext(ctx) + if err == nil { + return ExitOK + } + info, code := classify(err) + enc := json.NewEncoder(a.Stderr) + enc.SetIndent("", " ") + _ = enc.Encode(map[string]any{"error": info}) + return code +} + +func (a *App) rootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "teams", + Short: "Read Microsoft Teams data from the command line", + Long: `teams is a command-line interface for reading Microsoft Teams data via +Microsoft Graph. It is designed to be invoked by AI coding agents and scripts: +results are written to stdout as JSON, errors are written to stderr as JSON +({"error": {"code": ..., "message": ...}}) and exit codes are stable: + + 0 success + 1 unexpected error + 2 usage error (bad flags/arguments) + 3 not logged in / session expired (run "teams login") + 4 team, channel or message not found, or name is ambiguous + 5 Microsoft Graph API error (e.g. forbidden, throttled) + +Authenticate once with "teams login", which opens a browser window.`, + Example: ` teams login + teams search --channel platform-engineering --since 30d --query "private endpoints" + teams messages --team "Platform" --channel general --since 7d + teams thread --channel platform-engineering --id 1717171717171 + teams post --channel platform-engineering --message "Deployment is complete"`, + Version: Version, + SilenceErrors: true, + SilenceUsage: true, + } + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return newUsageError(err) }) + pf := root.PersistentFlags() + pf.StringVarP(&a.format, "format", "f", "json", "output format: json or text") + pf.StringVar(&a.clientID, "client-id", "", "Entra ID application (client) ID [env "+config.EnvClientID+"]") + pf.StringVar(&a.tenant, "tenant", "", "Entra ID tenant ID or domain [env "+config.EnvTenant+"] (default \""+config.DefaultTenant+"\")") + root.PersistentPreRunE = func(*cobra.Command, []string) error { + if a.format != "json" && a.format != "text" { + return newUsageError(fmt.Errorf("invalid --format %q: must be json or text", a.format)) + } + return nil + } + + root.AddCommand( + a.loginCmd(), + a.logoutCmd(), + a.whoamiCmd(), + a.teamsCmd(), + a.channelsCmd(), + a.chatsCmd(), + a.chatMessagesCmd(), + a.chatPostCmd(), + a.messagesCmd(), + a.searchCmd(), + a.threadCmd(), + a.postCmd(), + ) + return root +} + +// authSettings returns the effective client ID and tenant. +func (a *App) authSettings() (dir, clientID, tenant string, err error) { + dir, err = config.Dir() + if err != nil { + return "", "", "", err + } + saved, err := config.Load(dir) + if err != nil { + return "", "", "", err + } + clientID = config.Resolve(a.clientID, config.EnvClientID, saved.ClientID, config.DefaultClientID) + tenant = config.Resolve(a.tenant, config.EnvTenant, saved.Tenant, config.DefaultTenant) + return dir, clientID, tenant, nil +} + +func (a *App) authenticator() (*auth.Authenticator, string, error) { + dir, clientID, tenant, err := a.authSettings() + if err != nil { + return nil, "", err + } + au, err := auth.New(clientID, tenant, dir) + return au, dir, err +} + +func (a *App) defaultGraph(context.Context) (*graph.Client, error) { + base := os.Getenv(config.EnvGraphURL) + if base == "" { + base = config.DefaultGraphURL + } + if tok, ok := auth.EnvToken(); ok { + return graph.NewClient(base, tok), nil + } + au, _, err := a.authenticator() + if err != nil { + return nil, err + } + return graph.NewClient(base, au), nil +} + +func (a *App) service(ctx context.Context) (*teams.Service, error) { + g, err := a.NewGraph(ctx) + if err != nil { + return nil, err + } + return &teams.Service{Graph: g}, nil +} + +// emit writes v to stdout as JSON, or via textFn when --format text. +func (a *App) emit(v any, textFn func(w io.Writer)) error { + if a.format == "text" && textFn != nil { + textFn(a.Stdout) + return nil + } + enc := json.NewEncoder(a.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0908473 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,128 @@ +// Package config resolves CLI settings and on-disk locations. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + // DefaultClientID is the public "Microsoft Graph Command Line Tools" + // application, which supports delegated Graph permissions and a + // http://localhost redirect URI for interactive browser sign-in. + DefaultClientID = "14d82eec-204b-4c2f-b7e8-296a70dab67e" + // DefaultTenant accepts any work or school account. + DefaultTenant = "organizations" + // DefaultGraphURL is the Microsoft Graph v1.0 endpoint. + DefaultGraphURL = "https://graph.microsoft.com/v1.0" + + EnvConfigDir = "TEAMS_CLI_CONFIG_DIR" + EnvClientID = "TEAMS_CLI_CLIENT_ID" + EnvTenant = "TEAMS_CLI_TENANT_ID" + EnvAccessToken = "TEAMS_CLI_ACCESS_TOKEN" + EnvGraphURL = "TEAMS_CLI_GRAPH_URL" + + settingsFile = "config.json" + cacheFile = "msal_cache.json" +) + +// Settings are persisted at login so later commands reuse the same app +// registration and tenant. +type Settings struct { + ClientID string `json:"clientId,omitempty"` + Tenant string `json:"tenant,omitempty"` +} + +// Dir returns the directory used to store CLI state. +func Dir() (string, error) { + if d := os.Getenv(EnvConfigDir); d != "" { + return d, nil + } + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("cannot determine config directory: %w", err) + } + return filepath.Join(base, "teams-cli"), nil +} + +// CachePath returns the path of the token cache file. +func CachePath(dir string) string { return filepath.Join(dir, cacheFile) } + +// Load reads saved settings from dir. A missing file yields empty settings. +func Load(dir string) (Settings, error) { + var s Settings + b, err := os.ReadFile(filepath.Join(dir, settingsFile)) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return s, err + } + if err := json.Unmarshal(b, &s); err != nil { + return s, fmt.Errorf("invalid %s: %w", settingsFile, err) + } + return s, nil +} + +// Save writes settings to dir. +func Save(dir string, s Settings) error { + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return WriteFileAtomic(filepath.Join(dir, settingsFile), b) +} + +// Remove deletes all persisted state (settings and token cache). +func Remove(dir string) error { + for _, name := range []string{settingsFile, cacheFile} { + if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} + +// Resolve picks the effective value using precedence flag > env > saved > default. +func Resolve(flag, envKey, saved, def string) string { + if flag != "" { + return flag + } + if v := os.Getenv(envKey); v != "" { + return v + } + if saved != "" { + return saved + } + return def +} + +// WriteFileAtomic writes data to path with owner-only permissions, creating +// the parent directory if needed. +func WriteFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b104209 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,51 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestSaveLoadRemove(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested") + if s, err := Load(dir); err != nil || s != (Settings{}) { + t.Fatalf("Load missing = %+v, %v", s, err) + } + want := Settings{ClientID: "client", Tenant: "contoso.onmicrosoft.com"} + if err := Save(dir, want); err != nil { + t.Fatal(err) + } + if got, err := Load(dir); err != nil || got != want { + t.Fatalf("Load = %+v, %v", got, err) + } + if runtime.GOOS != "windows" { + fi, err := os.Stat(filepath.Join(dir, settingsFile)) + if err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("expected 0600 permissions, got %v %v", fi.Mode(), err) + } + } + if err := Remove(dir); err != nil { + t.Fatal(err) + } + if err := Remove(dir); err != nil { + t.Fatalf("Remove should be idempotent: %v", err) + } +} + +func TestResolvePrecedence(t *testing.T) { + t.Setenv(EnvTenant, "") + if got := Resolve("", EnvTenant, "", "def"); got != "def" { + t.Errorf("default: %s", got) + } + if got := Resolve("", EnvTenant, "saved", "def"); got != "saved" { + t.Errorf("saved: %s", got) + } + t.Setenv(EnvTenant, "env") + if got := Resolve("", EnvTenant, "saved", "def"); got != "env" { + t.Errorf("env: %s", got) + } + if got := Resolve("flag", EnvTenant, "saved", "def"); got != "flag" { + t.Errorf("flag: %s", got) + } +} diff --git a/internal/graph/client.go b/internal/graph/client.go new file mode 100644 index 0000000..a57fd4e --- /dev/null +++ b/internal/graph/client.go @@ -0,0 +1,246 @@ +// Package graph is a minimal Microsoft Graph client for Teams data. +package graph + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// TokenSource supplies bearer tokens for Graph requests. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// Client performs authenticated requests against Microsoft Graph. +type Client struct { + BaseURL string + HTTP *http.Client + Tokens TokenSource + MaxRetries int + // Sleep is used to wait between retries; overridable for tests. + Sleep func(ctx context.Context, d time.Duration) error +} + +// NewClient returns a Client with sensible defaults. +func NewClient(baseURL string, tokens TokenSource) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTP: &http.Client{Timeout: 60 * time.Second}, + Tokens: tokens, + MaxRetries: 4, + Sleep: sleepCtx, + } +} + +// APIError is returned for non-2xx Graph responses. +type APIError struct { + StatusCode int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *APIError) Error() string { + return fmt.Sprintf("graph API error %d %s: %s", e.StatusCode, e.Code, e.Message) +} + +// IsStatus reports whether err is an APIError with the given HTTP status. +func IsStatus(err error, status int) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == status +} + +// Get fetches path (relative to BaseURL, or an absolute URL on the same host +// such as an @odata.nextLink) and decodes the JSON response into out. +func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { + u, err := c.resolve(path, query) + if err != nil { + return err + } + for attempt := 0; ; attempt++ { + retryAfter, err := c.do(ctx, u, out) + if err == nil { + return nil + } + if retryAfter < 0 || attempt >= c.MaxRetries { + return err + } + if err := c.Sleep(ctx, retryAfter); err != nil { + return err + } + } +} + +// Post sends a JSON request to Microsoft Graph and decodes its JSON response. +// It does not retry because repeating a POST can create duplicate resources. +func (c *Client) Post(ctx context.Context, path string, body, out any) error { + u, err := c.resolve(path, nil) + if err != nil { + return err + } + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding Microsoft Graph request: %w", err) + } + _, err = c.request(ctx, http.MethodPost, u, payload, out) + return err +} + +// do performs a single request. It returns a non-negative retry delay when the +// request may be retried. +func (c *Client) do(ctx context.Context, u string, out any) (time.Duration, error) { + return c.request(ctx, http.MethodGet, u, nil, out) +} + +func (c *Client) request(ctx context.Context, method, u string, body []byte, out any) (time.Duration, error) { + token, err := c.Tokens.Token(ctx) + if err != nil { + return -1, err + } + req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body)) + if err != nil { + return -1, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.HTTP.Do(req) + if err != nil { + if ctx.Err() != nil { + return -1, ctx.Err() + } + return time.Second, fmt.Errorf("request to Microsoft Graph failed: %w", err) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + return time.Second, fmt.Errorf("reading Microsoft Graph response: %w", err) + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if out == nil { + return -1, nil + } + if err := json.Unmarshal(responseBody, out); err != nil { + return -1, fmt.Errorf("decoding Microsoft Graph response: %w", err) + } + return -1, nil + } + + apiErr := &APIError{StatusCode: resp.StatusCode, Message: http.StatusText(resp.StatusCode)} + var payload struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(responseBody, &payload) == nil && payload.Error.Code != "" { + apiErr.Code = payload.Error.Code + apiErr.Message = payload.Error.Message + } + switch resp.StatusCode { + case http.StatusTooManyRequests, http.StatusServiceUnavailable, http.StatusGatewayTimeout, http.StatusBadGateway: + return retryDelay(resp.Header.Get("Retry-After")), apiErr + } + return -1, apiErr +} + +func (c *Client) resolve(path string, query url.Values) (string, error) { + var u *url.URL + var err error + if strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "http://") { + u, err = url.Parse(path) + if err != nil { + return "", err + } + base, err := url.Parse(c.BaseURL) + if err != nil { + return "", err + } + // Never send the bearer token to a host other than Graph. + if u.Host != base.Host || u.Scheme != base.Scheme { + return "", fmt.Errorf("refusing to follow link to unexpected host %q", u.Host) + } + } else { + u, err = url.Parse(c.BaseURL + "/" + strings.TrimLeft(path, "/")) + if err != nil { + return "", err + } + } + if len(query) > 0 { + q := u.Query() + for k, vs := range query { + for _, v := range vs { + q.Add(k, v) + } + } + // Graph expects literal "$" in OData parameter names. + u.RawQuery = strings.ReplaceAll(q.Encode(), "%24", "$") + } + return u.String(), nil +} + +func retryDelay(h string) time.Duration { + if s, err := strconv.Atoi(strings.TrimSpace(h)); err == nil && s >= 0 { + d := time.Duration(s) * time.Second + if d > 60*time.Second { + d = 60 * time.Second + } + return d + } + return 2 * time.Second +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// page is the envelope of a Graph collection response. +type page[T any] struct { + Value []T `json:"value"` + NextLink string `json:"@odata.nextLink"` +} + +// Pages iterates over a Graph collection, calling fn with each page of items. +// Iteration stops when fn returns false or there are no more pages. +func Pages[T any](ctx context.Context, c *Client, path string, query url.Values, fn func([]T) bool) error { + next := path + q := query + for next != "" { + var p page[T] + if err := c.Get(ctx, next, q, &p); err != nil { + return err + } + if !fn(p.Value) { + return nil + } + next, q = p.NextLink, nil + } + return nil +} + +// All collects every item from a Graph collection. +func All[T any](ctx context.Context, c *Client, path string, query url.Values) ([]T, error) { + var out []T + err := Pages(ctx, c, path, query, func(items []T) bool { + out = append(out, items...) + return true + }) + return out, err +} diff --git a/internal/graph/client_test.go b/internal/graph/client_test.go new file mode 100644 index 0000000..b2b14c8 --- /dev/null +++ b/internal/graph/client_test.go @@ -0,0 +1,107 @@ +package graph + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +type tok string + +func (t tok) Token(context.Context) (string, error) { return string(t), nil } + +func TestGetRetriesOnThrottling(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "Bearer "+"abc" { + t.Errorf("missing bearer token") + } + if calls < 3 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"id":"1","displayName":"Jane"}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, tok("abc")) + var slept []time.Duration + c.Sleep = func(_ context.Context, d time.Duration) error { slept = append(slept, d); return nil } + me, err := c.Me(context.Background()) + if err != nil { + t.Fatal(err) + } + if me.DisplayName != "Jane" || calls != 3 || len(slept) != 2 || slept[0] != time.Second { + t.Fatalf("me=%+v calls=%d slept=%v", me, calls, slept) + } +} + +func TestGetReturnsAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":"Forbidden","message":"Missing scope"}}`)) + })) + defer srv.Close() + + _, err := NewClient(srv.URL, tok("abc")).Me(context.Background()) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 403 || apiErr.Code != "Forbidden" || apiErr.Message != "Missing scope" { + t.Fatalf("unexpected error: %#v", err) + } + if !IsStatus(err, 403) { + t.Fatal("IsStatus should report 403") + } +} + +func TestPostSendsJSONAndDecodesResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/teams/team-1/channels/channel-1/messages" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer abc" || r.Header.Get("Content-Type") != "application/json" { + t.Errorf("missing auth/content headers: %v", r.Header) + } + var request struct { + Body ItemBody `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + if request.Body.ContentType != "text" || request.Body.Content != "hello" { + t.Errorf("unexpected request body: %+v", request.Body) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"new-1","body":{"contentType":"text","content":"hello"}}`)) + })) + defer srv.Close() + + message, err := NewClient(srv.URL, tok("abc")).PostChannelMessage(context.Background(), "team-1", "channel-1", "hello") + if err != nil || message.ID != "new-1" || message.Body.Content != "hello" { + t.Fatalf("message=%+v err=%v", message, err) + } +} + +func TestResolveKeepsODataDollarAndRejectsForeignHosts(t *testing.T) { + c := NewClient("https://graph.microsoft.com/v1.0", tok("x")) + u, err := c.resolve("/teams/a/channels/19:abc@thread.tacv2/messages", url.Values{"$top": {"50"}, "$expand": {"replies"}}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(u, "$top=50") || !strings.Contains(u, "$expand=replies") || !strings.Contains(u, "/channels/19:abc@thread.tacv2/messages") { + t.Fatalf("unexpected url %s", u) + } + if _, err := c.resolve("https://evil.example.com/v1.0/me", nil); err == nil { + t.Fatal("expected foreign host to be rejected") + } + if _, err := c.resolve("https://graph.microsoft.com/v1.0/me?$skiptoken=x", nil); err != nil { + t.Fatal(err) + } +} diff --git a/internal/graph/graphtest/fixtures.go b/internal/graph/graphtest/fixtures.go new file mode 100644 index 0000000..7561d98 --- /dev/null +++ b/internal/graph/graphtest/fixtures.go @@ -0,0 +1,68 @@ +package graphtest + +import ( + "time" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph" +) + +// Msg builds a channel message created at t with an HTML body. +func Msg(id string, t time.Time, author, html string, replies ...graph.ChatMessage) graph.ChatMessage { + for i := range replies { + replies[i].ReplyToID = id + } + return graph.ChatMessage{ + ID: id, + MessageType: "message", + CreatedDateTime: t, + From: &graph.IdentitySet{User: &graph.Identity{ID: "u-" + author, DisplayName: author}}, + Body: graph.ItemBody{ContentType: "html", Content: html}, + WebURL: "https://teams.microsoft.com/l/message/" + id, + Replies: replies, + } +} + +// Seed populates s with two teams: +// +// Platform (team-1): "Platform Engineering" (19:pe), "General" (19:gen1) +// Data (team-2): "General" (19:gen2) +// +// Messages in "Platform Engineering" are relative to now, newest thread first. +func (s *Server) Seed(now time.Time) { + day := 24 * time.Hour + s.Me = graph.User{ID: "me-1", DisplayName: "Test User", UserPrincipalName: "test@example.com"} + s.Teams = []graph.Team{{ID: "team-1", DisplayName: "Platform"}, {ID: "team-2", DisplayName: "Data"}} + s.Channels["team-1"] = []graph.Channel{ + {ID: "19:pe@thread.tacv2", DisplayName: "Platform Engineering", MembershipType: "standard"}, + {ID: "19:gen1@thread.tacv2", DisplayName: "General", MembershipType: "standard"}, + } + s.Channels["team-2"] = []graph.Channel{ + {ID: "19:gen2@thread.tacv2", DisplayName: "General", MembershipType: "standard"}, + } + system := Msg("sys", now.Add(-1*day), "", "") + system.MessageType = "systemEventMessage" + deleted := Msg("del", now.Add(-2*day), "Bob", "private endpoints secret") + deletedAt := now.Add(-2 * day) + deleted.DeletedDateTime = &deletedAt + s.Messages["19:pe@thread.tacv2"] = []graph.ChatMessage{ + system, + Msg("m1", now.Add(-3*day), "Alice", "

Should we use Private Endpoints for ACR?

", + Msg("r1", now.Add(-2*day), "Bob", "

Yes, private endpoints plus DNS zones.

"), + Msg("r2", now.Add(-2*day+time.Hour), "Carol", "

Agreed

"), + ), + deleted, + Msg("m2", now.Add(-10*day), "Dave", "

Cluster upgrade tonight

"), + // Old thread with a recent reply mentioning the search terms. + Msg("m3", now.Add(-60*day), "Erin", "

Networking notes

", + Msg("r3", now.Add(-5*day), "Frank", "

Update: private endpoints are now enabled

"), + ), + Msg("m4", now.Add(-45*day), "Gina", "

Old private endpoints discussion

"), + Msg("m5", now.Add(-90*day), "Hank", "

Very old private endpoints

"), + Msg("m6", now.Add(-120*day), "Ivan", "

Ancient private endpoints

"), + Msg("m7", now.Add(-150*day), "Judy", "

Prehistoric private endpoints

"), + } + s.Messages["19:gen1@thread.tacv2"] = []graph.ChatMessage{ + Msg("g1", now.Add(-1*day), "Alice", "

Welcome! private endpoints FAQ is pinned.

"), + } + s.Messages["19:gen2@thread.tacv2"] = []graph.ChatMessage{} +} diff --git a/internal/graph/graphtest/server.go b/internal/graph/graphtest/server.go new file mode 100644 index 0000000..a0c2294 --- /dev/null +++ b/internal/graph/graphtest/server.go @@ -0,0 +1,273 @@ +// Package graphtest provides an in-memory fake of the Microsoft Graph Teams +// endpoints used by the CLI, for tests. +package graphtest + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph" +) + +// Token is the bearer token the fake server accepts. +const Token = "test-token" + +// Server is a fake Graph server. +type Server struct { + *httptest.Server + Me graph.User + Teams []graph.Team + Chats []graph.Chat + Channels map[string][]graph.Channel // by team ID + Messages map[string][]graph.ChatMessage // root messages (with replies) by channel ID + ChatMessages map[string][]graph.ChatMessage // messages by chat ID + // Forbidden channel IDs return 403 for message requests. + Forbidden map[string]bool + // PageSize overrides the page size for message listings (default: $top). + PageSize int + + mu sync.Mutex + requests []string + nextID int +} + +// New starts a fake Graph server that is closed when the test ends. +func New(t *testing.T) *Server { + s := &Server{ + Channels: map[string][]graph.Channel{}, + Messages: map[string][]graph.ChatMessage{}, + ChatMessages: map[string][]graph.ChatMessage{}, + Forbidden: map[string]bool{}, + } + s.Server = httptest.NewServer(http.HandlerFunc(s.handle)) + t.Cleanup(s.Close) + return s +} + +// Client returns a Graph client pointed at the fake server. +func (s *Server) Client() *graph.Client { + return graph.NewClient(s.URL+"/v1.0", staticToken(Token)) +} + +// Requests returns the request URIs received so far. +func (s *Server) Requests() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.requests...) +} + +type staticToken string + +func (t staticToken) Token(context.Context) (string, error) { return string(t), nil } + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.requests = append(s.requests, r.URL.RequestURI()) + s.mu.Unlock() + + if r.Header.Get("Authorization") != "Bearer "+Token { + writeErr(w, http.StatusUnauthorized, "InvalidAuthenticationToken", "Access token is empty.") + return + } + parts := strings.Split(strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1.0"), "/"), "/") + switch { + case len(parts) == 1 && parts[0] == "me": + writeJSON(w, s.Me) + case len(parts) == 2 && parts[0] == "me" && parts[1] == "joinedTeams": + writeJSON(w, map[string]any{"value": s.Teams}) + case len(parts) == 2 && parts[0] == "me" && parts[1] == "chats": + writeJSON(w, map[string]any{"value": s.Chats}) + case len(parts) >= 3 && parts[0] == "chats" && parts[2] == "messages": + s.handleChatMessages(w, r, parts[1], parts[3:]) + case len(parts) == 2 && parts[0] == "teams": + for _, t := range s.Teams { + if t.ID == parts[1] { + writeJSON(w, t) + return + } + } + writeErr(w, http.StatusNotFound, "NotFound", "team not found") + case len(parts) == 3 && parts[0] == "teams" && parts[2] == "channels": + chans, ok := s.Channels[parts[1]] + if !ok { + writeErr(w, http.StatusNotFound, "NotFound", "team not found") + return + } + writeJSON(w, map[string]any{"value": chans}) + case len(parts) >= 5 && parts[0] == "teams" && parts[2] == "channels" && parts[4] == "messages": + s.handleMessages(w, r, parts[3], parts[5:]) + default: + writeErr(w, http.StatusNotFound, "UnknownPath", r.URL.Path) + } +} + +func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request, chatID string, rest []string) { + msgs, ok := s.ChatMessages[chatID] + if !ok { + writeErr(w, http.StatusNotFound, "NotFound", "chat not found") + return + } + if len(rest) != 0 { + writeErr(w, http.StatusNotFound, "UnknownPath", r.URL.Path) + return + } + if r.Method == http.MethodPost { + var request struct { + Body graph.ItemBody `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Body.Content == "" { + writeErr(w, http.StatusBadRequest, "InvalidRequest", "message body is required") + return + } + s.mu.Lock() + s.nextID++ + id := fmt.Sprintf("chat-posted-%d", s.nextID) + s.mu.Unlock() + created := graph.ChatMessage{ + ID: id, ChatID: chatID, MessageType: "message", CreatedDateTime: time.Now().UTC(), + Body: request.Body, + From: &graph.IdentitySet{User: &graph.Identity{ID: s.Me.ID, DisplayName: s.Me.DisplayName}}, + } + s.ChatMessages[chatID] = append([]graph.ChatMessage{created}, msgs...) + writeJSON(w, created) + return + } + size := s.PageSize + if size == 0 { + size, _ = strconv.Atoi(r.URL.Query().Get("$top")) + } + if size <= 0 { + size = 20 + } + skip, _ := strconv.Atoi(r.URL.Query().Get("$skiptoken")) + end := min(skip+size, len(msgs)) + page := append([]graph.ChatMessage(nil), msgs[skip:end]...) + resp := map[string]any{"value": page} + if end < len(msgs) { + resp["@odata.nextLink"] = s.URL + r.URL.EscapedPath() + "?$top=" + strconv.Itoa(size) + "&$skiptoken=" + strconv.Itoa(end) + } + writeJSON(w, resp) +} + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request, channelID string, rest []string) { + if s.Forbidden[channelID] { + writeErr(w, http.StatusForbidden, "Forbidden", "Missing role permissions on the request.") + return + } + msgs, ok := s.Messages[channelID] + if !ok { + writeErr(w, http.StatusNotFound, "NotFound", "channel not found") + return + } + find := func(id string) (graph.ChatMessage, bool) { + for _, m := range msgs { + if m.ID == id { + return m, true + } + } + return graph.ChatMessage{}, false + } + q := r.URL.Query() + if r.Method == http.MethodPost { + var request struct { + Body graph.ItemBody `json:"body"` + } + payload, err := io.ReadAll(r.Body) + if err != nil || json.Unmarshal(payload, &request) != nil || request.Body.Content == "" { + writeErr(w, http.StatusBadRequest, "InvalidRequest", "message body is required") + return + } + s.mu.Lock() + s.nextID++ + id := fmt.Sprintf("posted-%d", s.nextID) + s.mu.Unlock() + created := graph.ChatMessage{ + ID: id, MessageType: "message", CreatedDateTime: time.Now().UTC(), + Body: request.Body, + From: &graph.IdentitySet{User: &graph.Identity{ID: s.Me.ID, DisplayName: s.Me.DisplayName}}, + } + if len(rest) == 0 { + s.Messages[channelID] = append([]graph.ChatMessage{created}, msgs...) + } else if len(rest) == 2 && rest[1] == "replies" { + for i := range msgs { + if msgs[i].ID == rest[0] { + created.ReplyToID = rest[0] + msgs[i].Replies = append(msgs[i].Replies, created) + s.Messages[channelID] = msgs + writeJSON(w, created) + return + } + } + writeErr(w, http.StatusNotFound, "NotFound", "message not found") + return + } else { + writeErr(w, http.StatusNotFound, "UnknownPath", r.URL.Path) + return + } + created.WebURL = "https://teams.microsoft.com/l/message/" + created.ID + writeJSON(w, created) + return + } + switch len(rest) { + case 0: + size := s.PageSize + if size == 0 { + size, _ = strconv.Atoi(q.Get("$top")) + } + if size <= 0 { + size = 20 + } + skip, _ := strconv.Atoi(q.Get("$skiptoken")) + end := min(skip+size, len(msgs)) + page := make([]graph.ChatMessage, 0, end-skip) + for _, m := range msgs[skip:end] { + if q.Get("$expand") != "replies" { + m.Replies = nil + } + page = append(page, m) + } + resp := map[string]any{"value": page} + if end < len(msgs) { + resp["@odata.nextLink"] = s.URL + r.URL.EscapedPath() + "?$top=" + strconv.Itoa(size) + + "&$expand=" + q.Get("$expand") + "&$skiptoken=" + strconv.Itoa(end) + } + writeJSON(w, resp) + case 1: + m, ok := find(rest[0]) + if !ok { + writeErr(w, http.StatusNotFound, "NotFound", "message not found") + return + } + m.Replies = nil + writeJSON(w, m) + case 2: + m, ok := find(rest[0]) + if !ok || rest[1] != "replies" { + writeErr(w, http.StatusNotFound, "NotFound", "message not found") + return + } + writeJSON(w, map[string]any{"value": m.Replies}) + default: + writeErr(w, http.StatusNotFound, "UnknownPath", r.URL.Path) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": code, "message": msg}}) +} diff --git a/internal/graph/teams.go b/internal/graph/teams.go new file mode 100644 index 0000000..4fdf00a --- /dev/null +++ b/internal/graph/teams.go @@ -0,0 +1,202 @@ +package graph + +import ( + "context" + "net/url" + "strconv" + "time" +) + +// Team is a Microsoft Teams team. +type Team struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + +// Channel is a channel within a team. +type Channel struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + MembershipType string `json:"membershipType"` + WebURL string `json:"webUrl"` +} + +// Chat is a one-to-one, group, or meeting chat. +type Chat struct { + ID string `json:"id"` + ChatType string `json:"chatType"` + Topic string `json:"topic"` + LastUpdatedDateTime *time.Time `json:"lastUpdatedDateTime"` +} + +// Identity is a user, application or device identity. +type Identity struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` +} + +// IdentitySet identifies the sender of a message. +type IdentitySet struct { + User *Identity `json:"user"` + Application *Identity `json:"application"` + Device *Identity `json:"device"` +} + +// ItemBody is the body of a message. +type ItemBody struct { + ContentType string `json:"contentType"` + Content string `json:"content"` +} + +// Attachment is a file, card or other item attached to a message. +type Attachment struct { + ID string `json:"id"` + ContentType string `json:"contentType"` + ContentURL string `json:"contentUrl"` + Name string `json:"name"` +} + +// ChatMessage is a message in a chat or channel. +type ChatMessage struct { + ID string `json:"id"` + ChatID string `json:"chatId"` + ReplyToID string `json:"replyToId"` + MessageType string `json:"messageType"` + CreatedDateTime time.Time `json:"createdDateTime"` + LastModifiedDateTime *time.Time `json:"lastModifiedDateTime"` + DeletedDateTime *time.Time `json:"deletedDateTime"` + Subject string `json:"subject"` + Importance string `json:"importance"` + WebURL string `json:"webUrl"` + From *IdentitySet `json:"from"` + Body ItemBody `json:"body"` + Attachments []Attachment `json:"attachments"` + Replies []ChatMessage `json:"replies"` +} + +// LatestActivity returns the most recent timestamp on the message or any of +// its (expanded) replies. +func (m ChatMessage) LatestActivity() time.Time { + t := m.CreatedDateTime + if m.LastModifiedDateTime != nil && m.LastModifiedDateTime.After(t) { + t = *m.LastModifiedDateTime + } + for _, r := range m.Replies { + if a := r.LatestActivity(); a.After(t) { + t = a + } + } + return t +} + +// User is the signed-in user profile. +type User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + UserPrincipalName string `json:"userPrincipalName"` + Mail string `json:"mail"` +} + +// Me returns the signed-in user's profile. +func (c *Client) Me(ctx context.Context) (User, error) { + var u User + err := c.Get(ctx, "/me", url.Values{"$select": {"id,displayName,userPrincipalName,mail"}}, &u) + return u, err +} + +// JoinedTeams lists the teams the signed-in user is a member of. +func (c *Client) JoinedTeams(ctx context.Context) ([]Team, error) { + return All[Team](ctx, c, "/me/joinedTeams", url.Values{"$select": {"id,displayName,description"}}) +} + +// Team fetches a single team by ID. +func (c *Client) Team(ctx context.Context, teamID string) (Team, error) { + var t Team + err := c.Get(ctx, "/teams/"+url.PathEscape(teamID), url.Values{"$select": {"id,displayName,description"}}, &t) + return t, err +} + +// Channels lists the channels of a team. +func (c *Client) Channels(ctx context.Context, teamID string) ([]Channel, error) { + return All[Channel](ctx, c, "/teams/"+url.PathEscape(teamID)+"/channels", nil) +} + +// PrivateChats lists the signed-in user's one-to-one and group chats. +func (c *Client) PrivateChats(ctx context.Context) ([]Chat, error) { + chats, err := All[Chat](ctx, c, "/me/chats", url.Values{"$select": {"id,chatType,topic,lastUpdatedDateTime"}}) + if err != nil { + return nil, err + } + private := make([]Chat, 0, len(chats)) + for _, chat := range chats { + if chat.ChatType == "oneOnOne" || chat.ChatType == "group" { + private = append(private, chat) + } + } + return private, nil +} + +// ChatMessages returns all messages in a chat, newest activity first. +func (c *Client) ChatMessages(ctx context.Context, chatID string) ([]ChatMessage, error) { + return All[ChatMessage](ctx, c, chatMessagesPath(chatID), url.Values{"$top": {"50"}}) +} + +// PostChatMessage creates a message in a one-to-one or group chat. +func (c *Client) PostChatMessage(ctx context.Context, chatID, content string) (ChatMessage, error) { + return c.postMessage(ctx, chatMessagesPath(chatID), content) +} + +// ChannelMessagePages iterates over root messages in a channel (newest +// threads first), with replies expanded, calling fn for each page. +func (c *Client) ChannelMessagePages(ctx context.Context, teamID, channelID string, pageSize int, fn func([]ChatMessage) bool) error { + if pageSize <= 0 || pageSize > 50 { + pageSize = 50 + } + q := url.Values{ + "$top": {strconv.Itoa(pageSize)}, + "$expand": {"replies"}, + } + return Pages(ctx, c, channelMessagesPath(teamID, channelID), q, fn) +} + +// ChannelMessage fetches a single root message. +func (c *Client) ChannelMessage(ctx context.Context, teamID, channelID, messageID string) (ChatMessage, error) { + var m ChatMessage + err := c.Get(ctx, channelMessagesPath(teamID, channelID)+"/"+url.PathEscape(messageID), nil, &m) + return m, err +} + +// Replies lists all replies to a root message. +func (c *Client) Replies(ctx context.Context, teamID, channelID, messageID string) ([]ChatMessage, error) { + return All[ChatMessage](ctx, c, channelMessagesPath(teamID, channelID)+"/"+url.PathEscape(messageID)+"/replies", + url.Values{"$top": {"50"}}) +} + +// PostChannelMessage creates a root message in a channel. +func (c *Client) PostChannelMessage(ctx context.Context, teamID, channelID, content string) (ChatMessage, error) { + return c.postMessage(ctx, channelMessagesPath(teamID, channelID), content) +} + +// ReplyToChannelMessage creates a reply to a root channel message. +func (c *Client) ReplyToChannelMessage(ctx context.Context, teamID, channelID, messageID, content string) (ChatMessage, error) { + path := channelMessagesPath(teamID, channelID) + "/" + url.PathEscape(messageID) + "/replies" + return c.postMessage(ctx, path, content) +} + +func (c *Client) postMessage(ctx context.Context, path, content string) (ChatMessage, error) { + var message ChatMessage + err := c.Post(ctx, path, struct { + Body ItemBody `json:"body"` + }{Body: ItemBody{ContentType: "text", Content: content}}, &message) + return message, err +} + +func channelMessagesPath(teamID, channelID string) string { + return "/teams/" + url.PathEscape(teamID) + "/channels/" + url.PathEscape(channelID) + "/messages" +} + +func chatMessagesPath(chatID string) string { + return "/chats/" + url.PathEscape(chatID) + "/messages" +} diff --git a/internal/query/match.go b/internal/query/match.go new file mode 100644 index 0000000..43efb5b --- /dev/null +++ b/internal/query/match.go @@ -0,0 +1,61 @@ +package query + +import ( + "strings" + "unicode" +) + +// Matcher performs case-insensitive matching of search terms against text. +// Every term must be present for a match (logical AND). Double-quoted +// sections are treated as a single phrase term. +type Matcher struct { + terms []string +} + +// NewMatcher parses q into a Matcher. An empty query matches everything. +func NewMatcher(q string) Matcher { + var terms []string + var cur strings.Builder + inQuote := false + flush := func() { + if t := normalize(cur.String()); t != "" { + terms = append(terms, t) + } + cur.Reset() + } + for _, r := range q { + switch { + case r == '"': + flush() + inQuote = !inQuote + case unicode.IsSpace(r) && !inQuote: + flush() + default: + cur.WriteRune(r) + } + } + flush() + return Matcher{terms: terms} +} + +// Terms returns the normalized terms used for matching. +func (m Matcher) Terms() []string { return m.terms } + +// Match reports whether all terms occur in the given texts combined. +func (m Matcher) Match(texts ...string) bool { + if len(m.terms) == 0 { + return true + } + haystack := normalize(strings.Join(texts, "\n")) + for _, t := range m.terms { + if !strings.Contains(haystack, t) { + return false + } + } + return true +} + +// normalize lower-cases s and collapses all runs of whitespace to one space. +func normalize(s string) string { + return strings.Join(strings.Fields(strings.ToLower(s)), " ") +} diff --git a/internal/query/query_test.go b/internal/query/query_test.go new file mode 100644 index 0000000..fa9246a --- /dev/null +++ b/internal/query/query_test.go @@ -0,0 +1,60 @@ +package query + +import ( + "testing" + "time" +) + +func TestParseSince(t *testing.T) { + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + cases := map[string]time.Time{ + "": {}, + "30d": now.AddDate(0, 0, -30), + "30 days": now.AddDate(0, 0, -30), + "12h": now.Add(-12 * time.Hour), + "90m": now.Add(-90 * time.Minute), + "2w": now.AddDate(0, 0, -14), + "3mo": now.AddDate(0, -3, 0), + "1y": now.AddDate(-1, 0, 0), + "2026-09-01": time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), + "2026-09-01T10:30:00Z": time.Date(2026, 9, 1, 10, 30, 0, 0, time.UTC), + } + for in, want := range cases { + got, err := ParseSince(in, now) + if err != nil { + t.Errorf("ParseSince(%q) error: %v", in, err) + continue + } + if !got.Equal(want) { + t.Errorf("ParseSince(%q) = %v, want %v", in, got, want) + } + } + for _, bad := range []string{"yesterday", "30x", "-5d", "2026-13-01"} { + if _, err := ParseSince(bad, now); err == nil { + t.Errorf("ParseSince(%q) expected error", bad) + } + } +} + +func TestMatcher(t *testing.T) { + cases := []struct { + query, text string + want bool + }{ + {"private endpoints", "We need Private Endpoints for the storage account", true}, + {"private endpoints", "the endpoint is private", false}, + {"private endpoint", "the endpoint is private", true}, + {`"private endpoint"`, "the endpoint is private", false}, + {`"private endpoint" dns`, "Private\nEndpoint DNS zones", true}, + {"", "anything", true}, + {"kubernetes", "AKS cluster", false}, + } + for _, c := range cases { + if got := NewMatcher(c.query).Match(c.text); got != c.want { + t.Errorf("NewMatcher(%q).Match(%q) = %v, want %v", c.query, c.text, got, c.want) + } + } + if got := NewMatcher(`"Private Endpoint" DNS`).Terms(); len(got) != 2 || got[0] != "private endpoint" || got[1] != "dns" { + t.Errorf("unexpected terms %q", got) + } +} diff --git a/internal/query/since.go b/internal/query/since.go new file mode 100644 index 0000000..224bf96 --- /dev/null +++ b/internal/query/since.go @@ -0,0 +1,54 @@ +// Package query contains helpers for filtering Teams messages by time and text. +package query + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +var relativeRe = regexp.MustCompile(`^(\d+)\s*(m|min|mins|h|hr|hrs|d|day|days|w|wk|wks|week|weeks|mo|month|months|y|yr|yrs|year|years)$`) + +// ParseSince converts a user-supplied "since" value into an absolute time. +// +// Supported forms: +// - relative durations: 90m, 12h, 30d, 2w, 3mo, 1y +// - dates: 2024-05-01 (interpreted as UTC midnight) +// - RFC 3339 timestamps: 2024-05-01T09:00:00Z +// +// An empty string returns the zero time (no lower bound). +func ParseSince(s string, now time.Time) (time.Time, error) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, nil + } + if m := relativeRe.FindStringSubmatch(strings.ToLower(s)); m != nil { + n, err := strconv.Atoi(m[1]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid since value %q: %w", s, err) + } + switch m[2] { + case "m", "min", "mins": + return now.Add(-time.Duration(n) * time.Minute), nil + case "h", "hr", "hrs": + return now.Add(-time.Duration(n) * time.Hour), nil + case "d", "day", "days": + return now.AddDate(0, 0, -n), nil + case "w", "wk", "wks", "week", "weeks": + return now.AddDate(0, 0, -7*n), nil + case "mo", "month", "months": + return now.AddDate(0, -n, 0), nil + case "y", "yr", "yrs", "year", "years": + return now.AddDate(-n, 0, 0), nil + } + } + if t, err := time.Parse(time.RFC3339, strings.ToUpper(s)); err == nil { + return t, nil + } + if t, err := time.Parse("2006-01-02", s); err == nil { + return t, nil + } + return time.Time{}, fmt.Errorf("invalid since value %q: use a relative duration (e.g. 30d, 12h, 2w, 3mo), a date (YYYY-MM-DD) or an RFC 3339 timestamp", s) +} diff --git a/internal/teams/chats.go b/internal/teams/chats.go new file mode 100644 index 0000000..283c044 --- /dev/null +++ b/internal/teams/chats.go @@ -0,0 +1,69 @@ +package teams + +import ( + "context" + "time" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph" + "github.com/glenthomas/microsoft-teams-cli/internal/textutil" +) + +// ChatMessage is a plain-text representation of a private chat message. +type ChatMessage struct { + ID string `json:"id"` + ChatID string `json:"chatId"` + ReplyToID string `json:"replyToId,omitempty"` + Author string `json:"author,omitempty"` + AuthorID string `json:"authorId,omitempty"` + CreatedDateTime time.Time `json:"createdDateTime"` + LastModifiedDateTime *time.Time `json:"lastModifiedDateTime,omitempty"` + Text string `json:"text"` + WebURL string `json:"webUrl,omitempty"` +} + +// ListPrivateChats returns the signed-in user's one-to-one and group chats. +func (s *Service) ListPrivateChats(ctx context.Context) ([]graph.Chat, error) { + return s.Graph.PrivateChats(ctx) +} + +// ChatMessages returns all messages in a private chat, newest first. +func (s *Service) ChatMessages(ctx context.Context, chatID string) ([]ChatMessage, error) { + items, err := s.Graph.ChatMessages(ctx, chatID) + if err != nil { + return nil, err + } + messages := make([]ChatMessage, 0, len(items)) + for _, item := range items { + if item.DeletedDateTime != nil || item.MessageType != "" && item.MessageType != "message" { + continue + } + messages = append(messages, newChatMessage(item)) + } + return messages, nil +} + +// PostChatMessage sends a message to a one-to-one or group chat. +func (s *Service) PostChatMessage(ctx context.Context, chatID, content string) (ChatMessage, error) { + created, err := s.Graph.PostChatMessage(ctx, chatID, content) + if err != nil { + return ChatMessage{}, err + } + return newChatMessage(created), nil +} + +func newChatMessage(m graph.ChatMessage) ChatMessage { + out := ChatMessage{ + ID: m.ID, ChatID: m.ChatID, ReplyToID: m.ReplyToID, + CreatedDateTime: m.CreatedDateTime, LastModifiedDateTime: m.LastModifiedDateTime, + Text: textutil.BodyToText(m.Body.ContentType, m.Body.Content), WebURL: m.WebURL, + } + if m.From != nil { + for _, id := range []*graph.Identity{m.From.User, m.From.Application, m.From.Device} { + if id != nil { + out.Author, out.AuthorID = id.DisplayName, id.ID + break + } + } + } + return out +} diff --git a/internal/teams/messages.go b/internal/teams/messages.go new file mode 100644 index 0000000..e55be56 --- /dev/null +++ b/internal/teams/messages.go @@ -0,0 +1,234 @@ +package teams + +import ( + "context" + "sort" + "time" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph" + "github.com/glenthomas/microsoft-teams-cli/internal/query" + "github.com/glenthomas/microsoft-teams-cli/internal/textutil" +) + +// Message is the flattened, agent-friendly representation of a channel +// message or reply. +type Message struct { + ID string `json:"id"` + Type string `json:"type"` // "message" or "reply" + ThreadID string `json:"threadId"` + TeamID string `json:"teamId"` + TeamName string `json:"teamName"` + ChannelID string `json:"channelId"` + ChannelName string `json:"channelName"` + Author string `json:"author,omitempty"` + AuthorID string `json:"authorId,omitempty"` + CreatedDateTime time.Time `json:"createdDateTime"` + LastModifiedDateTime *time.Time `json:"lastModifiedDateTime,omitempty"` + Subject string `json:"subject,omitempty"` + Importance string `json:"importance,omitempty"` + Text string `json:"text"` + WebURL string `json:"webUrl,omitempty"` + ReplyCount *int `json:"replyCount,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +// Attachment is a simplified message attachment. +type Attachment struct { + Name string `json:"name,omitempty"` + ContentType string `json:"contentType,omitempty"` + URL string `json:"url,omitempty"` +} + +// NewMessage converts a Graph message into a Message. +func NewMessage(ch ChannelRef, m graph.ChatMessage, threadID string) Message { + out := Message{ + ID: m.ID, + Type: "message", + ThreadID: threadID, + TeamID: ch.TeamID, + TeamName: ch.TeamName, + ChannelID: ch.ChannelID, + ChannelName: ch.ChannelName, + CreatedDateTime: m.CreatedDateTime, + LastModifiedDateTime: m.LastModifiedDateTime, + Subject: m.Subject, + Text: textutil.BodyToText(m.Body.ContentType, m.Body.Content), + WebURL: m.WebURL, + } + if m.ReplyToID != "" || m.ID != threadID { + out.Type = "reply" + } + if m.Importance != "" && m.Importance != "normal" { + out.Importance = m.Importance + } + if m.From != nil { + for _, id := range []*graph.Identity{m.From.User, m.From.Application, m.From.Device} { + if id != nil { + out.Author, out.AuthorID = id.DisplayName, id.ID + break + } + } + } + for _, a := range m.Attachments { + // Inline message references carry no useful standalone content. + if a.ContentType == "messageReference" { + continue + } + out.Attachments = append(out.Attachments, Attachment{Name: a.Name, ContentType: a.ContentType, URL: a.ContentURL}) + } + return out +} + +// isUserMessage reports whether m is a regular, non-deleted message. +func isUserMessage(m graph.ChatMessage) bool { + return m.DeletedDateTime == nil && (m.MessageType == "" || m.MessageType == "message") +} + +// CollectOptions controls which messages are collected from a channel. +type CollectOptions struct { + // Since and Until bound message creation time; zero values are unbounded. + Since, Until time.Time + // Matcher filters messages by text; an empty matcher matches all. + Matcher query.Matcher + // IncludeReplies includes replies in addition to root messages. + IncludeReplies bool + // MaxThreads caps the number of threads scanned per channel (0 = no cap). + MaxThreads int +} + +// CollectResult is the outcome of scanning one channel. +type CollectResult struct { + Messages []Message + ThreadsScanned int + // Truncated is set when MaxThreads was reached before the time window + // was exhausted, so older matches may be missing. + Truncated bool +} + +func (o CollectOptions) inWindow(t time.Time) bool { + if !o.Since.IsZero() && t.Before(o.Since) { + return false + } + if !o.Until.IsZero() && !t.Before(o.Until) { + return false + } + return true +} + +// Collect scans a channel's threads (newest first) and returns messages that +// fall in the time window and match the query. Scanning stops once a whole +// page of threads has had no activity since opts.Since. +func (s *Service) Collect(ctx context.Context, ch ChannelRef, opts CollectOptions) (CollectResult, error) { + var res CollectResult + err := s.Graph.ChannelMessagePages(ctx, ch.TeamID, ch.ChannelID, 50, func(page []graph.ChatMessage) bool { + active := false + for _, root := range page { + if !opts.Since.IsZero() && root.LatestActivity().Before(opts.Since) { + continue + } + active = true + if opts.MaxThreads > 0 && res.ThreadsScanned >= opts.MaxThreads { + res.Truncated = true + return false + } + res.ThreadsScanned++ + res.Messages = append(res.Messages, matchThread(ch, root, opts)...) + } + return active || opts.Since.IsZero() && len(page) > 0 + }) + if err != nil { + return res, err + } + SortNewestFirst(res.Messages) + return res, nil +} + +func matchThread(ch ChannelRef, root graph.ChatMessage, opts CollectOptions) []Message { + var out []Message + consider := func(m graph.ChatMessage, isRoot bool) { + if !isUserMessage(m) || !opts.inWindow(m.CreatedDateTime) { + return + } + msg := NewMessage(ch, m, root.ID) + if !opts.Matcher.Match(msg.Subject, msg.Text) { + return + } + if isRoot { + n := countUserMessages(root.Replies) + msg.ReplyCount = &n + } + out = append(out, msg) + } + consider(root, true) + if opts.IncludeReplies { + for _, r := range root.Replies { + consider(r, false) + } + } + return out +} + +func countUserMessages(ms []graph.ChatMessage) int { + n := 0 + for _, m := range ms { + if isUserMessage(m) { + n++ + } + } + return n +} + +// SortNewestFirst orders messages by creation time, newest first. +func SortNewestFirst(ms []Message) { + sort.SliceStable(ms, func(i, j int) bool { return ms[i].CreatedDateTime.After(ms[j].CreatedDateTime) }) +} + +// Thread returns a root message and all of its replies, oldest first. +func (s *Service) Thread(ctx context.Context, ch ChannelRef, messageID string) ([]Message, error) { + root, err := s.Graph.ChannelMessage(ctx, ch.TeamID, ch.ChannelID, messageID) + if err != nil { + return nil, err + } + // Allow a reply ID to be passed: fetch its root thread instead. + if root.ReplyToID != "" && root.ReplyToID != root.ID { + return s.Thread(ctx, ch, root.ReplyToID) + } + replies, err := s.Graph.Replies(ctx, ch.TeamID, ch.ChannelID, root.ID) + if err != nil { + return nil, err + } + var out []Message + if isUserMessage(root) { + m := NewMessage(ch, root, root.ID) + n := countUserMessages(replies) + m.ReplyCount = &n + out = append(out, m) + } + for _, r := range replies { + if isUserMessage(r) { + out = append(out, NewMessage(ch, r, root.ID)) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedDateTime.Before(out[j].CreatedDateTime) }) + return out, nil +} + +// PostMessage creates a channel message, or a reply when replyTo is non-empty. +func (s *Service) PostMessage(ctx context.Context, ch ChannelRef, content, replyTo string) (Message, error) { + var ( + created graph.ChatMessage + err error + threadID string + ) + if replyTo == "" { + created, err = s.Graph.PostChannelMessage(ctx, ch.TeamID, ch.ChannelID, content) + threadID = created.ID + } else { + created, err = s.Graph.ReplyToChannelMessage(ctx, ch.TeamID, ch.ChannelID, replyTo, content) + threadID = replyTo + } + if err != nil { + return Message{}, err + } + return NewMessage(ch, created, threadID), nil +} diff --git a/internal/teams/resolve.go b/internal/teams/resolve.go new file mode 100644 index 0000000..efb866c --- /dev/null +++ b/internal/teams/resolve.go @@ -0,0 +1,225 @@ +// Package teams implements the Teams-level operations used by the CLI: +// resolving teams and channels by name, and collecting/searching messages. +package teams + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" + "unicode" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph" +) + +// Service wraps a Graph client with Teams-specific helpers. +type Service struct { + Graph *graph.Client +} + +// ChannelRef identifies a channel together with its parent team. +type ChannelRef struct { + TeamID string `json:"teamId"` + TeamName string `json:"teamName"` + ChannelID string `json:"channelId"` + ChannelName string `json:"channelName"` + MembershipType string `json:"membershipType,omitempty"` + WebURL string `json:"webUrl,omitempty"` +} + +// NotFoundError is returned when a team or channel cannot be resolved. +type NotFoundError struct { + Kind string `json:"kind"` + Name string `json:"name"` + Available []string `json:"available,omitempty"` +} + +func (e *NotFoundError) Error() string { + msg := fmt.Sprintf("%s %q not found", e.Kind, e.Name) + if len(e.Available) > 0 { + msg += "; available: " + strings.Join(e.Available, ", ") + } + return msg +} + +// AmbiguousError is returned when a name matches more than one channel/team. +type AmbiguousError struct { + Kind string `json:"kind"` + Name string `json:"name"` + Matches []ChannelRef `json:"matches"` +} + +func (e *AmbiguousError) Error() string { + var names []string + for _, m := range e.Matches { + if e.Kind == "team" { + names = append(names, fmt.Sprintf("%s (%s)", m.TeamName, m.TeamID)) + } else { + names = append(names, fmt.Sprintf("%s/%s", m.TeamName, m.ChannelName)) + } + } + return fmt.Sprintf("%s %q is ambiguous; use --team to disambiguate. Matches: %s", e.Kind, e.Name, strings.Join(names, ", ")) +} + +var guidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// nameScore rates how well a user-supplied name matches a display name: +// 2 = case-insensitive exact, 1 = equal ignoring punctuation/spacing +// (so "platform-engineering" matches "Platform Engineering"), 0 = no match. +func nameScore(query, name string) int { + if strings.EqualFold(strings.TrimSpace(query), strings.TrimSpace(name)) { + return 2 + } + if q := squash(query); q != "" && q == squash(name) { + return 1 + } + return 0 +} + +func squash(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// ResolveTeams returns the joined teams matching teamArg (ID or display +// name). An empty teamArg returns all joined teams. +func (s *Service) ResolveTeams(ctx context.Context, teamArg string) ([]graph.Team, error) { + joined, err := s.Graph.JoinedTeams(ctx) + if err != nil { + return nil, err + } + if teamArg == "" { + return joined, nil + } + for _, t := range joined { + if strings.EqualFold(t.ID, teamArg) { + return []graph.Team{t}, nil + } + } + best, matches := 0, []graph.Team(nil) + for _, t := range joined { + sc := nameScore(teamArg, t.DisplayName) + switch { + case sc == 0: + case sc > best: + best, matches = sc, []graph.Team{t} + case sc == best: + matches = append(matches, t) + } + } + switch len(matches) { + case 1: + return matches, nil + case 0: + if guidRe.MatchString(teamArg) { + t, err := s.Graph.Team(ctx, teamArg) + if err == nil { + return []graph.Team{t}, nil + } + } + names := make([]string, 0, len(joined)) + for _, t := range joined { + names = append(names, t.DisplayName) + } + return nil, &NotFoundError{Kind: "team", Name: teamArg, Available: capNames(names)} + default: + amb := &AmbiguousError{Kind: "team", Name: teamArg} + for _, t := range matches { + amb.Matches = append(amb.Matches, ChannelRef{TeamID: t.ID, TeamName: t.DisplayName}) + } + return nil, amb + } +} + +// ListChannels returns all channels in the teams matching teamArg. +func (s *Service) ListChannels(ctx context.Context, teamArg string) ([]ChannelRef, error) { + teams, err := s.ResolveTeams(ctx, teamArg) + if err != nil { + return nil, err + } + var out []ChannelRef + for _, t := range teams { + chans, err := s.Graph.Channels(ctx, t.ID) + if err != nil { + // Skip teams whose channels the user cannot read when listing + // across all teams; surface the error for an explicit team. + if teamArg == "" && (graph.IsStatus(err, 403) || graph.IsStatus(err, 404)) { + continue + } + return nil, err + } + for _, c := range chans { + out = append(out, ChannelRef{ + TeamID: t.ID, TeamName: t.DisplayName, + ChannelID: c.ID, ChannelName: c.DisplayName, + MembershipType: c.MembershipType, WebURL: c.WebURL, + }) + } + } + return out, nil +} + +// ResolveChannels resolves the channels to operate on. channelArg may be a +// channel ID (19:...) or display name; if empty, all channels of the team(s) +// are returned. At least one of teamArg or channelArg must be set. +func (s *Service) ResolveChannels(ctx context.Context, teamArg, channelArg string) ([]ChannelRef, error) { + if teamArg == "" && channelArg == "" { + return nil, fmt.Errorf("at least one of --channel or --team is required") + } + all, err := s.ListChannels(ctx, teamArg) + if err != nil { + return nil, err + } + if channelArg == "" { + return all, nil + } + for _, c := range all { + if c.ChannelID == channelArg { + return []ChannelRef{c}, nil + } + } + best, matches := 0, []ChannelRef(nil) + for _, c := range all { + sc := nameScore(channelArg, c.ChannelName) + switch { + case sc == 0: + case sc > best: + best, matches = sc, []ChannelRef{c} + case sc == best: + matches = append(matches, c) + } + } + switch len(matches) { + case 1: + return matches, nil + case 0: + seen := map[string]bool{} + var names []string + for _, c := range all { + if !seen[c.ChannelName] { + seen[c.ChannelName] = true + names = append(names, c.ChannelName) + } + } + return nil, &NotFoundError{Kind: "channel", Name: channelArg, Available: capNames(names)} + default: + return nil, &AmbiguousError{Kind: "channel", Name: channelArg, Matches: matches} + } +} + +// maxAvailableNames bounds the suggestions included in a NotFoundError. +const maxAvailableNames = 100 + +func capNames(names []string) []string { + sort.Strings(names) + if len(names) > maxAvailableNames { + names = append(names[:maxAvailableNames:maxAvailableNames], "...") + } + return names +} diff --git a/internal/teams/teams_test.go b/internal/teams/teams_test.go new file mode 100644 index 0000000..ceb28c8 --- /dev/null +++ b/internal/teams/teams_test.go @@ -0,0 +1,142 @@ +package teams + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/glenthomas/microsoft-teams-cli/internal/graph/graphtest" + "github.com/glenthomas/microsoft-teams-cli/internal/query" +) + +var now = time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + +func newService(t *testing.T) (*Service, *graphtest.Server) { + srv := graphtest.New(t) + srv.Seed(now) + return &Service{Graph: srv.Client()}, srv +} + +func TestResolveChannels(t *testing.T) { + svc, _ := newService(t) + ctx := context.Background() + + chans, err := svc.ResolveChannels(ctx, "", "platform-engineering") + if err != nil || len(chans) != 1 || chans[0].ChannelID != "19:pe@thread.tacv2" || chans[0].TeamName != "Platform" { + t.Fatalf("normalized name: %+v %v", chans, err) + } + chans, err = svc.ResolveChannels(ctx, "", "19:gen2@thread.tacv2") + if err != nil || len(chans) != 1 || chans[0].TeamID != "team-2" { + t.Fatalf("by id: %+v %v", chans, err) + } + chans, err = svc.ResolveChannels(ctx, "data", "general") + if err != nil || len(chans) != 1 || chans[0].ChannelID != "19:gen2@thread.tacv2" { + t.Fatalf("with team: %+v %v", chans, err) + } + chans, err = svc.ResolveChannels(ctx, "team-1", "") + if err != nil || len(chans) != 2 { + t.Fatalf("whole team: %+v %v", chans, err) + } + + var amb *AmbiguousError + if _, err := svc.ResolveChannels(ctx, "", "General"); !errors.As(err, &amb) || len(amb.Matches) != 2 { + t.Fatalf("expected ambiguous error, got %v", err) + } + var nf *NotFoundError + if _, err := svc.ResolveChannels(ctx, "", "nope"); !errors.As(err, &nf) || len(nf.Available) != 2 { + t.Fatalf("expected not found with suggestions, got %v", err) + } + if _, err := svc.ResolveChannels(ctx, "missing-team", "general"); !errors.As(err, &nf) || nf.Kind != "team" { + t.Fatalf("expected team not found, got %v", err) + } +} + +func ids(ms []Message) []string { + var out []string + for _, m := range ms { + out = append(out, m.ID) + } + return out +} + +func TestCollectSearch(t *testing.T) { + svc, srv := newService(t) + srv.PageSize = 2 + ch := ChannelRef{TeamID: "team-1", TeamName: "Platform", ChannelID: "19:pe@thread.tacv2", ChannelName: "Platform Engineering"} + + res, err := svc.Collect(context.Background(), ch, CollectOptions{ + Since: now.AddDate(0, 0, -30), + Matcher: query.NewMatcher("private endpoints"), + IncludeReplies: true, + }) + if err != nil { + t.Fatal(err) + } + // r1 (2d), m1 (3d), r3 (5d, reply in an old thread). Deleted, system and + // out-of-window messages are excluded. + got := ids(res.Messages) + want := []string{"r1", "m1", "r3"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } + m1 := res.Messages[1] + if m1.Type != "message" || m1.ThreadID != "m1" || m1.Author != "Alice" || m1.ReplyCount == nil || *m1.ReplyCount != 2 || + m1.Text != "Should we use Private Endpoints for ACR?" { + t.Fatalf("unexpected root message %+v", m1) + } + if r1 := res.Messages[0]; r1.Type != "reply" || r1.ThreadID != "m1" { + t.Fatalf("unexpected reply %+v", r1) + } + + // Paging should stop at the first page with no activity since --since + // (m5/m6), so the final page (m7) is never requested. + msgReqs := 0 + for _, r := range srv.Requests() { + if strings.Contains(r, "/messages") { + msgReqs++ + } + } + if msgReqs != 4 { + t.Fatalf("expected 4 message page requests, got %d: %v", msgReqs, srv.Requests()) + } +} + +func TestCollectNoRepliesAndMaxThreads(t *testing.T) { + svc, _ := newService(t) + ch := ChannelRef{TeamID: "team-1", ChannelID: "19:pe@thread.tacv2"} + + res, err := svc.Collect(context.Background(), ch, CollectOptions{Matcher: query.NewMatcher("private endpoints")}) + if err != nil { + t.Fatal(err) + } + if got := ids(res.Messages); len(got) != 5 || got[0] != "m1" { // m1, m4, m5, m6, m7 + t.Fatalf("no-replies got %v", got) + } + + res, err = svc.Collect(context.Background(), ch, CollectOptions{MaxThreads: 2}) + if err != nil { + t.Fatal(err) + } + if !res.Truncated || res.ThreadsScanned != 2 { + t.Fatalf("expected truncation after 2 threads, got %+v", res) + } +} + +func TestThread(t *testing.T) { + svc, _ := newService(t) + ch := ChannelRef{TeamID: "team-1", ChannelID: "19:pe@thread.tacv2"} + msgs, err := svc.Thread(context.Background(), ch, "m1") + if err != nil { + t.Fatal(err) + } + if got := ids(msgs); len(got) != 3 || got[0] != "m1" || got[1] != "r1" || got[2] != "r2" { + t.Fatalf("thread got %v", got) + } +} diff --git a/internal/textutil/html.go b/internal/textutil/html.go new file mode 100644 index 0000000..78490a8 --- /dev/null +++ b/internal/textutil/html.go @@ -0,0 +1,48 @@ +// Package textutil converts Teams message bodies into plain text. +package textutil + +import ( + "html" + "regexp" + "strings" +) + +var ( + blockTagRe = regexp.MustCompile(`(?i)<\s*(br|/p|/div|/li|/tr|/h[1-6]|/blockquote|/pre)\b[^>]*>`) + listItemRe = regexp.MustCompile(`(?i)<\s*li\b[^>]*>`) + scriptLikeRe = regexp.MustCompile(`(?is)<\s*(script|style)\b.*?<\s*/\s*(script|style)\s*>`) + tagRe = regexp.MustCompile(`(?s)<[^>]*>`) + spaceRe = regexp.MustCompile(`[ \t\f\v\x{00a0}]+`) + blankLinesRe = regexp.MustCompile(`\n{3,}`) +) + +// HTMLToText strips HTML markup from s, preserving line breaks for block +// elements and decoding HTML entities. +func HTMLToText(s string) string { + if s == "" { + return "" + } + s = scriptLikeRe.ReplaceAllString(s, "") + s = blockTagRe.ReplaceAllString(s, "\n") + s = listItemRe.ReplaceAllString(s, "- ") + s = tagRe.ReplaceAllString(s, "") + s = html.UnescapeString(s) + s = strings.ReplaceAll(s, "\r\n", "\n") + + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimSpace(spaceRe.ReplaceAllString(line, " ")) + } + s = strings.Join(lines, "\n") + s = blankLinesRe.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} + +// BodyToText returns the plain-text form of a message body with the given +// Graph contentType ("html" or "text"). +func BodyToText(contentType, content string) string { + if strings.EqualFold(contentType, "html") { + return HTMLToText(content) + } + return strings.TrimSpace(content) +} diff --git a/internal/textutil/html_test.go b/internal/textutil/html_test.go new file mode 100644 index 0000000..185dfa3 --- /dev/null +++ b/internal/textutil/html_test.go @@ -0,0 +1,22 @@ +package textutil + +import "testing" + +func TestHTMLToText(t *testing.T) { + cases := map[string]string{ + "": "", + "

Hello world & co

": "Hello world & co", + "
line1
line2
line3
": "line1\nline2\nline3", + "": "- a\n- b", + "

x

y

": "x\n\ny", + `Jane Doe please check`: "Jane Doe please check", + } + for in, want := range cases { + if got := HTMLToText(in); got != want { + t.Errorf("HTMLToText(%q) = %q, want %q", in, got, want) + } + } + if got := BodyToText("text", " "); got != "" { + t.Errorf("BodyToText text = %q", got) + } +}