diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ca0aa05..96af91a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -2,10 +2,10 @@ name: Python Integration Tests on: pull_request: - branches: [ main ] + branches: [main] types: [review_requested, ready_for_review] push: - branches: [ main ] + branches: [main] repository_dispatch: types: [verify-new-go-lib-version] @@ -237,6 +237,8 @@ jobs: # clean up unneeded files rm -rf python_release/tests + rm -rf python_release/docs + rm python_release/mkdocs.yml find python_release -type d -name "__pycache__" -exec rm -rf {} + rm -rf python_release/.venv diff --git a/README.md b/README.md index 7727354..a9913ec 100644 --- a/README.md +++ b/README.md @@ -1 +1,123 @@ -# lib-python +# Teams API Python Wrapper + +[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Documentation](https://img.shields.io/badge/docs-online-green)](https://pzsp-teams.github.io/lib-python/) + +
+ +A high-performance **Python wrapper** for the Microsoft Teams Graph API. It leverages a compiled **Go (Golang)** backend to handle heavy lifting, concurrency, and caching, while providing a clean, fully typed Pythonic interface. + +## 🚀 Key Features + +- **Performance of Go**: Uses a compiled Go subprocess for efficient API communication and state management. +- **Pythonic Interface**: Fully typed using `dataclasses` and `Enums`. Native support for IDE autocompletion and type checkers (`mypy`). +- **Intelligent Cache**: Background caching of Team/Channel IDs to minimize API throttling and latency (handled transparently by the backend). +- **Simplified Auth**: Wraps MSAL authentication flows configuration. + +## 📦 Installation + +```bash +pip install teams-lib-pzsp2-z1 +``` + +## 🛠️ Architecture + +This library acts as a bridge. When initialized, it spawns a dedicated Go subprocess (`teamsClientLib`) and communicates via JSON-RPC over `stdin`/`stdout`. + +The `TeamsClient` aggregates domain-specific services: + +- **client.teams**: Manage team lifecycles (create, list, delete). + +- **client.channels**: Manage standard and private channels. + +- **client.chats**: Handle messages, mentions, and chat participants. + +## 💻 Quick Start + +### 1. Configuration + +Create a `.env` in your project with your Azure AD credentials: + +```ini +CLIENT_ID=your-client-id-uuid +TENANT_ID=your-tenant-id-uuid +EMAIL=user@example.com +SCOPES=User.Read,Team.ReadBasic.All,Channel.ReadBasic.All +AUTH_METHOD=DEVICE_CODE [or INTERACTIVE] +``` + +Scopes needed by all functions could be found in [.env.template](https://github.com/pzsp-teams/lib-python/tree/main/.env.template). + +### 2. Basic usage + +Here is a minimal example showing how to initialize the client and list joined teams: + +```python +from teams_lib_pzsp2_z1.client import TeamsClient +from teams_lib_pzsp2_z1.config import CacheMode + +def main(): + # Initialize the client. + # This spawns the Go subprocess and performs authentication. + client = TeamsClient() + + try: + print("Fetching teams...") + teams = client.teams.list_my_joined() + + for team in teams: + print(f"Team: {team.display_name} | ID: {team.id}") + + # Example: List channels in the first team + channels = client.channels.list_channels(team.id) + print(f" -> Found {len(channels)} channels.") + + except RuntimeError as e: + print(f"An error occurred: {e}") + + finally: + # Crucial: Close the client to ensure background cache writes finish + # and the Go subprocess is terminated gracefully. + client.close() + +if __name__ == "__main__": + main() +``` + +## 📚 Documentation + +Full API reference, architecture details, and configuration guides are available here: + +👉 [Read the Documentation](https://pzsp-teams.github.io/lib-python/) + +## Authentication + +The library loads credentials from environment variables. Ensure your Azure App Registration has the necessary **API Permissions** (e.g., `Team.ReadBasic.All`, `Channel.ReadBasic.All`) granted in the Azure Portal. + +Supported Authentication Methods (`AUTH_METHOD`): + +- **INTERACTIVE**: Opens a browser window automatically. + +- **DEVICE_CODE**: Prints a code to the console to login via microsoft.com/devicelogin. + +## Cache & Lifecycle + +If caching is enabled (SYNC or ASYNC), the Go backend stores metadata locally (e.g., `teams_cache.json`) to speed up reference resolution. + +⚠️ Important: client.close() +Because the Go backend might be running background goroutines (especially for Async Cache), you **must** call `client.close()` before your script exits. This ensures: + +1. Pending cache writes are flushed to disk. + +2. The Go subprocess is terminated correctly. + +```python +client.close() +``` + +## 📄 License +This project is licensed under the MIT License - see the [LICENSE](https://github.com/pzsp-teams/lib-python/blob/main/LICENSE) file for details. + +## Port +This lib is also available in [GO](https://github.com/pzsp-teams/lib) diff --git a/go/bridge/main.go b/go/bridge/main.go index ec5052b..128be65 100644 --- a/go/bridge/main.go +++ b/go/bridge/main.go @@ -5,9 +5,11 @@ import ( "encoding/json" "fmt" "os" + "context" jsonClientLib "github.com/pzsp-teams/lib-python/internal/json-client" jsonModel "github.com/pzsp-teams/lib-python/internal/json-model" + "github.com/pzsp-teams/lib" ) var client *jsonClientLib.TeamsJSONClient @@ -51,19 +53,24 @@ func main() { continue } - switch req.Method { - case "listChannels": - channels, err := client.ListChannels(req.Params) - if err != nil { - respondError(writer, err) - } else { - respondResult(writer, channels) - } - default: + handler, exists := jsonClientLib.Handlers[req.Method] + if !exists { respondError(writer, fmt.Errorf("unknown method")) + continue + } + + result, err := handler(client, context.Background(), req.Params) + if err != nil { + respondError(writer, err) } + respondResult(writer, result) continue } - respondError(writer, fmt.Errorf("unknown request type")) + + if req.Type == "close" { + lib.Close() + respondResult(writer, "closed") + return + } } } diff --git a/go/bridge/responses.go b/go/bridge/responses.go index ff2c4f2..72dcedc 100644 --- a/go/bridge/responses.go +++ b/go/bridge/responses.go @@ -6,7 +6,7 @@ import ( ) type response struct { - Result interface{} `json:"result,omitempty"` + Result any `json:"result,omitempty"` Error string `json:"error,omitempty"` } @@ -29,7 +29,7 @@ func respondError(writer *bufio.Writer, err error) { writer.Flush() } -func respondResult(writer *bufio.Writer, result interface{}) { +func respondResult(writer *bufio.Writer, result any) { response := response{ Result: result, } diff --git a/go/go.mod b/go/go.mod index 75df08b..a736666 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,23 +4,25 @@ go 1.25.5 require ( github.com/microsoft/kiota-abstractions-go v1.9.3 - github.com/microsoftgraph/msgraph-sdk-go v1.90.0 - github.com/pzsp-teams/lib v0.0.0-20251213104555-e41d3adf599f + github.com/microsoftgraph/msgraph-sdk-go v1.93.0 + github.com/mitchellh/mapstructure v1.5.0 + github.com/pzsp-teams/lib v0.0.0-20260112132712-1335fb93c11f ) require ( - al.essio.dev/pkg/shellescape v1.5.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect + al.essio.dev/pkg/shellescape v1.6.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect - github.com/danieljoos/wincred v1.2.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/keybase/go-keychain v0.0.0-20230523030712-b5615109f100 // indirect + github.com/keybase/go-keychain v0.0.1 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/microsoft/kiota-authentication-azure-go v1.3.1 // indirect github.com/microsoft/kiota-http-go v1.5.4 // indirect @@ -30,13 +32,14 @@ require ( github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 // indirect + github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect github.com/zalando/go-keyring v0.2.6 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index e345e5c..6790c62 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,15 +1,17 @@ -al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= -al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= -github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -17,16 +19,18 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/keybase/go-keychain v0.0.0-20230523030712-b5615109f100 h1:rG3VnJUnAWyiv7qYmmdOdSapzz6HM+zb9/uRFr0T5EM= -github.com/keybase/go-keychain v0.0.0-20230523030712-b5615109f100/go.mod h1:qDHUvIjGZJUtdPtuP4WMu5/U4aVWbFw1MhlkJqCGmCQ= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -47,41 +51,73 @@ github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwb github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfdxZjnAZ4GOB9O7XP4+r5r/M= github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= -github.com/microsoftgraph/msgraph-sdk-go v1.90.0 h1:ygVeWfGB8TMO4rTFxtrYueZmj1mLqtDKW5UZ4iJwczU= -github.com/microsoftgraph/msgraph-sdk-go v1.90.0/go.mod h1:UdZWxbZiFvjPug9DYayD90JNiHjXyNRA39lEpcy3Kms= +github.com/microsoftgraph/msgraph-sdk-go v1.92.0 h1:LOTZ5un7tsSki0S072D+TIHsiOIpmGJnDkNps9Em6cw= +github.com/microsoftgraph/msgraph-sdk-go v1.92.0/go.mod h1:sue5+4Z9FCOon6pHgvC1djjybs9ZYB3LZaAGYI1Qcfo= +github.com/microsoftgraph/msgraph-sdk-go v1.93.0 h1:H4WgYetjWYpr79S2ylNJchFDQTBE0ldKx8sR0qxyRGQ= +github.com/microsoftgraph/msgraph-sdk-go v1.93.0/go.mod h1:sue5+4Z9FCOon6pHgvC1djjybs9ZYB3LZaAGYI1Qcfo= github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 h1:0SrIoFl7TQnMRrsi5TFaeNe0q8KO5lRzRp4GSCCL2So= github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0/go.mod h1:A1iXs+vjsRjzANxF6UeKv2ACExG7fqTwHHbwh1FL+EE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pzsp-teams/lib v0.0.0-20251208073120-ca1606f2b27d h1:hFdPrkbnUngxrGdMkPVZpvY76T6QgnXo87u9wzXgNWY= -github.com/pzsp-teams/lib v0.0.0-20251208073120-ca1606f2b27d/go.mod h1:uGrgoO+KmBc55Le/AeysA0d24HOCQHO55Uw5Z7naQo4= -github.com/pzsp-teams/lib v0.0.0-20251213104555-e41d3adf599f h1:tmXKJEWYl0f/H4n91wnfW1asgkHbftKgX5nbpafNHmU= -github.com/pzsp-teams/lib v0.0.0-20251213104555-e41d3adf599f/go.mod h1:EPtQVPz+HYZNsSpcoQ3QWLbMEz1O2qeIH5ojlYJLEic= +github.com/pzsp-teams/lib v0.0.0-20260101113908-bafa8ca37eae h1:Q0NnGhGtyUbPzDHxLsBc39cHMjZBCG4yPCzU3ocNY+s= +github.com/pzsp-teams/lib v0.0.0-20260101113908-bafa8ca37eae/go.mod h1:OUTZq+jo2AAzjV5p0w7c9VVuOKiHVWxuZhdzOhjOq5s= +github.com/pzsp-teams/lib v0.0.0-20260101185821-fa4110d82a33 h1:snmBQwIaJV1MBynhm8OKzvaKtpUva4wem3VKmyaVU0Q= +github.com/pzsp-teams/lib v0.0.0-20260101185821-fa4110d82a33/go.mod h1:OUTZq+jo2AAzjV5p0w7c9VVuOKiHVWxuZhdzOhjOq5s= +github.com/pzsp-teams/lib v0.0.0-20260107201101-e9e6f48e39e8 h1:1ifLBNVdw8TCLPzHnlQfgxlL8pbj/9MVquTLNiX2t0Q= +github.com/pzsp-teams/lib v0.0.0-20260107201101-e9e6f48e39e8/go.mod h1:BQwULf+u8TtbKR0U23MlZXgm2CVcLBJn2cS5gmR7L8M= +github.com/pzsp-teams/lib v0.0.0-20260107211439-3d301706e02c h1:ZtUkM66y/jg5g5kEQhteQeACuwKl+cOkRuGPlX1mkGg= +github.com/pzsp-teams/lib v0.0.0-20260107211439-3d301706e02c/go.mod h1:BQwULf+u8TtbKR0U23MlZXgm2CVcLBJn2cS5gmR7L8M= +github.com/pzsp-teams/lib v0.0.0-20260111170324-f66ca4f5d369 h1:TNB4hQ6u8aH0cekG1xbITdkAxgrY4FmwSWgzfI1e21Q= +github.com/pzsp-teams/lib v0.0.0-20260111170324-f66ca4f5d369/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260112132712-1335fb93c11f h1:MOnTg+X51/lukcWWjHyw9OPx9IzC6Q6TJsZXiaH/KnU= +github.com/pzsp-teams/lib v0.0.0-20260112132712-1335fb93c11f/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260113134422-e5326c621551 h1:w4nwFMJLwLYolig9U5r5HpVL0FhrPTP14NggM5/UX9M= +github.com/pzsp-teams/lib v0.0.0-20260113134422-e5326c621551/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260113134659-c00a30df42d1 h1:ykdfDBsQzNu0DCKA+bm20n3o6EtpVPPhXZNRUbhCsuI= +github.com/pzsp-teams/lib v0.0.0-20260113134659-c00a30df42d1/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260113135020-602a62d68535 h1:fWsO7MUGT+wblfPy0Cubpew9vWblm7+V0UdOUeqbEKU= +github.com/pzsp-teams/lib v0.0.0-20260113135020-602a62d68535/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260113135200-1428146acf2e h1:ir/+PHMWaP4x2hZnQB4L6IDz42//Enf/RDA6Ryvzo70= +github.com/pzsp-teams/lib v0.0.0-20260113135200-1428146acf2e/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260113135504-3f606125f3a8 h1:Ck+6z5keqy6aHZEH7Cpe+ozzQm7rv5dg0tRZf2016Zg= +github.com/pzsp-teams/lib v0.0.0-20260113135504-3f606125f3a8/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260113135852-d15a162b495f h1:iuSQEjA6JrHLPoNJHPhloQlo8lAvqQXFqcxR3CJTn5s= +github.com/pzsp-teams/lib v0.0.0-20260113135852-d15a162b495f/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= +github.com/pzsp-teams/lib v0.0.0-20260113152600-e9075fa2951a h1:Y/I0MRE60rwsutOcj1FlnyrsrrL6jdJIcxCnL2R80V4= +github.com/pzsp-teams/lib v0.0.0-20260113152600-e9075fa2951a/go.mod h1:bpgptBb8PaJV03buetvHnq6plkQHpF0WnY1K5iU31U8= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 h1:7hth9376EoQEd1hH4lAp3vnaLP2UMyxuMMghLKzDHyU= -github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 h1:gMBdYMTHt2mmTdXW8YfvRjRUZ0GhyGV+IqSH9H15bGw= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go/internal/json-client/decoders/decode-chatRef.go b/go/internal/json-client/decoders/decode-chatRef.go new file mode 100644 index 0000000..1bc85e7 --- /dev/null +++ b/go/internal/json-client/decoders/decode-chatRef.go @@ -0,0 +1,28 @@ +package decoders + +import ( + "github.com/pzsp-teams/lib/chats" +) + +type ChatRefDTO struct { + Ref string `json:"ref"` + Type string `json:"type"` +} + +const ( + ChatTypeGroup = "group" + ChatTypeOneOnOne = "oneOnOne" +) + +func GetChatRef(dto ChatRefDTO) chats.ChatRef { + switch dto.Type { + case ChatTypeGroup: + return chats.GroupChatRef{Ref: dto.Ref} + case ChatTypeOneOnOne: + return chats.OneOnOneChatRef{Ref: dto.Ref} + default: + return nil + } +} + + diff --git a/go/internal/json-client/decoders/decode-messageBody.go b/go/internal/json-client/decoders/decode-messageBody.go new file mode 100644 index 0000000..d9fc39b --- /dev/null +++ b/go/internal/json-client/decoders/decode-messageBody.go @@ -0,0 +1,6 @@ +package decoders + +type MessageBodyDTO struct { + ContentType string `json:"contentType"` + Content string `json:"content"` +} \ No newline at end of file diff --git a/go/internal/json-client/decoders/decode-nextLink.go b/go/internal/json-client/decoders/decode-nextLink.go new file mode 100644 index 0000000..584689d --- /dev/null +++ b/go/internal/json-client/decoders/decode-nextLink.go @@ -0,0 +1,12 @@ +package decoders + +type NextLinkDTO struct { + NextLink string `json:"nextLink"` +} + +func GetNextLink(dto *NextLinkDTO) *string { + if dto.NextLink == "" { + return nil + } + return &dto.NextLink +} \ No newline at end of file diff --git a/go/internal/json-client/decoders/decode-params.go b/go/internal/json-client/decoders/decode-params.go new file mode 100644 index 0000000..c6a18ef --- /dev/null +++ b/go/internal/json-client/decoders/decode-params.go @@ -0,0 +1,27 @@ +package decoders + +import ( + "github.com/mitchellh/mapstructure" +) + +func DecodeParams[T any](p any) (*T, error) { + var result T + + config := &mapstructure.DecoderConfig{ + Metadata: nil, + Result: &result, + TagName: "json", + WeaklyTypedInput: true, + } + + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return nil, err + } + + if err := decoder.Decode(p); err != nil { + return nil, err + } + + return &result, nil +} \ No newline at end of file diff --git a/go/internal/json-client/decoders/decode-searchOptions.go b/go/internal/json-client/decoders/decode-searchOptions.go new file mode 100644 index 0000000..5958b81 --- /dev/null +++ b/go/internal/json-client/decoders/decode-searchOptions.go @@ -0,0 +1,103 @@ +package decoders + +import ( + "time" + + "github.com/pzsp-teams/lib/search" +) + +type SearchConfigDTO struct { + MaxWorkers int `json:"maxWorkers"` +} + +type SearchPageDTO struct { + From *int `json:"from"` + Size *int `json:"size"` +} + +type SearchMessageOptionsDTO struct { + Query string `json:"query"` + SearchPage SearchPageDTO `json:"searchPage"` + From []string `json:"from"` + NotFrom []string `json:"notFrom"` + IsRead *bool `json:"isRead"` + IsMentioned *bool `json:"isMentioned"` + To []string `json:"to"` + NotTo []string `json:"notTo"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + Interval string `json:"interval"` + NotFromMe *bool `json:"notFromMe"` + NotToMe *bool `json:"notToMe"` + FromMe *bool `json:"fromMe"` + ToMe *bool `json:"toMe"` +} + +func DecodeSearchMessageOptions(dto *SearchMessageOptionsDTO) *search.SearchMessagesOptions { + if dto == nil { + return nil + } + + interval := search.TimeInterval(dto.Interval) + + options := &search.SearchMessagesOptions{} + if dto.Query != "" { + options.Query = &dto.Query + } + options.SearchPage = DecodeSearchPage(&dto.SearchPage) + if len(dto.From) > 0 { + options.From = dto.From + } + if len(dto.NotFrom) > 0 { + options.NotFrom = dto.NotFrom + } + options.IsRead = dto.IsRead + options.IsMentioned = dto.IsMentioned + if len(dto.To) > 0 { + options.To = dto.To + } + if len(dto.NotTo) > 0 { + options.NotTo = dto.NotTo + } + if !dto.StartTime.IsZero() { + options.StartTime = &dto.StartTime + } + if !dto.EndTime.IsZero() { + options.EndTime = &dto.EndTime + } + if dto.Interval != "" { + options.Interval = &interval + } + if dto.NotFromMe != nil { + options.NotFromMe = *dto.NotFromMe + } + if dto.NotToMe != nil { + options.NotToMe = *dto.NotToMe + } + if dto.FromMe != nil { + options.FromMe = *dto.FromMe + } + if dto.ToMe != nil { + options.ToMe = *dto.ToMe + } + + return options +} + +func DecodeSearchPage(dto *SearchPageDTO) *search.SearchPage { + if dto == nil { + return nil + } + + page := &search.SearchPage{} + if dto.From != nil { + from := int32(*dto.From) + page.From = &from + } + if dto.Size != nil { + size := int32(*dto.Size) + page.Size = &size + } + + return page +} diff --git a/go/internal/json-client/decoders/decode-timeRange.go b/go/internal/json-client/decoders/decode-timeRange.go new file mode 100644 index 0000000..e6f74b6 --- /dev/null +++ b/go/internal/json-client/decoders/decode-timeRange.go @@ -0,0 +1,33 @@ +package decoders + +import ( + "fmt" + "time" +) + +func DecodeTimeRange(startTime string, endTime string) (*time.Time, *time.Time, error) { + parseTime := func(s string) (time.Time, error) { + t, err := time.Parse(time.RFC3339, s) + if err == nil { + return t, nil + } + layoutNaive := "2006-01-02T15:04:05" + if len(s) > 19 { + layoutNaive = "2006-01-02T15:04:05.999999" + } + + return time.Parse(layoutNaive, s) + } + + parsedStartTime, err := parseTime(startTime) + if err != nil { + return nil, nil, fmt.Errorf("error parsing startTime ('%s'): %v", startTime, err) + } + + parsedEndTime, err := parseTime(endTime) + if err != nil { + return nil, nil, fmt.Errorf("error parsing endTime ('%s'): %v", endTime, err) + } + + return &parsedStartTime, &parsedEndTime, nil +} diff --git a/go/internal/json-client/decoders/decode-updateTeam.go b/go/internal/json-client/decoders/decode-updateTeam.go new file mode 100644 index 0000000..b5a1eea --- /dev/null +++ b/go/internal/json-client/decoders/decode-updateTeam.go @@ -0,0 +1,27 @@ +package decoders + +import ( + "github.com/pzsp-teams/lib/models" +) + +type UpdateTeamDTO struct { + DisplayName string `json:"displayName"` + Description string `json:"description"` + Visibility string `json:"visibility"` +} + +func GetUpdateTeam(dto *UpdateTeamDTO) models.TeamUpdate { + update := models.TeamUpdate{} + + if dto.DisplayName != "" { + update.DisplayName = &dto.DisplayName + } + if dto.Description != "" { + update.Description = &dto.Description + } + if dto.Visibility != "" { + update.Visibility = &dto.Visibility + } + + return update +} diff --git a/go/internal/json-client/fake-client.go b/go/internal/json-client/fake-client.go index 2d731ca..2882bdc 100644 --- a/go/internal/json-client/fake-client.go +++ b/go/internal/json-client/fake-client.go @@ -12,14 +12,15 @@ import ( azcore "github.com/microsoft/kiota-abstractions-go" graph "github.com/microsoftgraph/msgraph-sdk-go" - lib "github.com/pzsp-teams/lib" + "github.com/pzsp-teams/lib/config" + "github.com/pzsp-teams/lib" jsonModel "github.com/pzsp-teams/lib-python/internal/json-model" ) // --- 1. Fake Auth Provider --- type FakeAuthProvider struct{} -func (f *FakeAuthProvider) AuthenticateRequest(ctx context.Context, request *azcore.RequestInformation, additionalAuthenticationContext map[string]interface{}) error { +func (f *FakeAuthProvider) AuthenticateRequest(ctx context.Context, request *azcore.RequestInformation, additionalAuthenticationContext map[string]any) error { if request.Headers == nil { request.Headers = azcore.NewRequestHeaders() } @@ -54,14 +55,17 @@ func NewJSONClient(req jsonModel.Request) (*TeamsJSONClient, error) { return nil, fmt.Errorf("invalid mockServerUrl parameter") } - senderConfig := lib.SenderConfig{ + senderConfig := config.SenderConfig{ MaxRetries: 3, NextRetryDelay: 2, Timeout: 5, } - cacheEnabled := false - var cachePath *string = nil + cacheConfig := config.CacheConfig{ + Mode: config.CacheDisabled, + Provider: config.CacheProviderJSONFile, + Path: nil, + } hijackedHttpClient := &http.Client{ Transport: &HijackTransport{ @@ -85,7 +89,7 @@ func NewJSONClient(req jsonModel.Request) (*TeamsJSONClient, error) { graphClient := graph.NewGraphServiceClient(adapter) - client, err := lib.NewClientFromGraphClient(graphClient, &senderConfig, cacheEnabled, cachePath) + client, err := lib.NewClientFromGraphClient(graphClient, &senderConfig, &cacheConfig) if err != nil { return nil, err } diff --git a/go/internal/json-client/real-client.go b/go/internal/json-client/real-client.go index a00178c..1711f7a 100644 --- a/go/internal/json-client/real-client.go +++ b/go/internal/json-client/real-client.go @@ -5,7 +5,8 @@ package jsonclient import ( "context" - lib "github.com/pzsp-teams/lib" + "github.com/pzsp-teams/lib" + "github.com/pzsp-teams/lib/config" jsonModel "github.com/pzsp-teams/lib-python/internal/json-model" ) @@ -20,22 +21,26 @@ func NewJSONClient(req jsonModel.Request) (*TeamsJSONClient, error) { return nil, err } - cacheEnabled, err := jsonModel.ParseCacheEnabled(req.Params["cacheEnabled"]) + cacheMode, err := jsonModel.ParseCacheMode(req.Config.CacheMode) if err != nil { return nil, err } - - var cachePath *string - if cacheEnabled { - path, err := jsonModel.ParseCachePath(req.Params["cachePath"]) - if err != nil { + cachePath, err := jsonModel.ParseCachePath(req.Config.CachePath) + if err != nil { + if cacheMode == config.CacheDisabled { + cachePath = nil + } else { return nil, err } - cachePath = path + } + cacheConfig := config.CacheConfig{ + Mode: config.CacheDisabled, + Provider: config.CacheProviderJSONFile, + Path: cachePath, } - client, err := lib.NewClient(context.TODO(), authConfig, senderConfig, cacheEnabled, cachePath) + client, err := lib.NewClient(context.TODO(), authConfig, senderConfig, &cacheConfig) if err != nil { return nil, err } diff --git a/go/internal/json-client/request-map.go b/go/internal/json-client/request-map.go new file mode 100644 index 0000000..ffb4a1e --- /dev/null +++ b/go/internal/json-client/request-map.go @@ -0,0 +1,189 @@ +package jsonclient + +import ( + "context" +) + +type HandlerFunc func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) + +var Handlers = map[string]HandlerFunc{ + + // TEAMS + "getTeam": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetTeam(ctx, p) + }, + + "updateTeam": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.UpdateTeam(ctx, p) + }, + + "listMyJoined": func(c *TeamsJSONClient, ctx context.Context, _ map[string]any) (any, error) { + return c.ListMyJoined(ctx) + }, + + "createTeamViaGroup": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.CreateTeamViaGroup(ctx, p) + }, + + "createTeamFromTemplate": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.CreateTeamFromTemplate(ctx, p) + }, + + "archiveTeam": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ArchiveTeam(ctx, p) + }, + + "unarchiveTeam": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.UnarchiveTeam(ctx, p) + }, + + "deleteTeam": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.DeleteTeam(ctx, p) + }, + + "restoreDeletedTeam": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.RestoreDeletedTeam(ctx, p) + }, + + // CHANNELS + "listChannels": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListChannels(ctx, p) + }, + + "getChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetChannel(ctx, p) + }, + + "createStandardChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.CreateStandardChannel(ctx, p) + }, + + "createPrivateChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.CreatePrivateChannel(ctx, p) + }, + + "deleteChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.DeleteChannel(ctx, p) + }, + + "sendMessageToChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.SendMessageToChannel(ctx, p) + }, + + "sendReplyToChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.SendReplyToChannel(ctx, p) + }, + + "listMessagesInChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListMessagesInChannel(ctx, p) + }, + + "getMessageInChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetMessageInChannel(ctx, p) + }, + + "listMessageRepliesInChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListMessageRepliesInChannel(ctx, p) + }, + + "getMessageReplyInChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetMessageReplyInChannel(ctx, p) + }, + + "listChannelMembers": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListChannelMembers(ctx, p) + }, + + "addMemberToChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.AddMemberToChannel(ctx, p) + }, + + "removeMemberFromChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.RemoveMemberFromChannel(ctx, p) + }, + + "updateMemberRoleInChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.UpdateMemberInChannel(ctx, p) + }, + + "getMentionsInChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetMentionsInChannel(ctx, p) + }, + + "searchMessagesInChannel": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.SearchMessagesInChannel(ctx, p) + }, + + // CHATS + "createOneOnOneChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.CreateOneToOneChat(ctx, p) + }, + + "createGroupChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.CreateGroupChat(ctx, p) + }, + + "getChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetChat(ctx, p) + }, + + "addMemberToGroupChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.AddMemberToGroupChat(ctx, p) + }, + + "removeMemberFromGroupChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.RemoveMemberFromGroupChat(ctx, p) + }, + + "listMembersInGroupChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListGroupChatMembers(ctx, p) + }, + + "updateGroupChatTopic": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.UpdateGroupChatTopic(ctx, p) + }, + + "listMessagesInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListMessagesInChat(ctx, p) + }, + + "sendMessageInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.SendMessageInChat(ctx, p) + }, + + "deleteMessageInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.DeleteMessageInChat(ctx, p) + }, + + "getMessageInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetMessageInChat(ctx, p) + }, + + "listMyChats": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListMyChats(ctx, p) + }, + + "listMyChatMessages": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListMyChatMessages(ctx, p) + }, + + "listPinnedMessagesInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.ListPinnedMessagesInChat(ctx, p) + }, + + "pinMessageInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.PinMessageInChat(ctx, p) + }, + + "unpinMessageInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.UnpinMessageInChat(ctx, p) + }, + + "getMentionsInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.GetMentionsInChat(ctx, p) + }, + + "searchMessagesInChat": func(c *TeamsJSONClient, ctx context.Context, p map[string]any) (any, error) { + return c.SearchMessagesInChat(ctx, p) + }, +} diff --git a/go/internal/json-client/requests-channels.go b/go/internal/json-client/requests-channels.go index cdfb18a..1f3a2fa 100644 --- a/go/internal/json-client/requests-channels.go +++ b/go/internal/json-client/requests-channels.go @@ -2,22 +2,226 @@ package jsonclient import ( "context" - "fmt" + + "github.com/pzsp-teams/lib-python/internal/json-client/decoders" + "github.com/pzsp-teams/lib/models" + "github.com/pzsp-teams/lib/search" ) -type ListChannelsParams struct { +type listChannelsParams struct { + TeamRef string `json:"teamRef"` +} + +func (c *TeamsJSONClient) ListChannels(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params listChannelsParams) (any, error) { + return c.client.Channels.ListChannels(ctx, params.TeamRef) + }) +} + +type baseChannelParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` +} + +func (c *TeamsJSONClient) GetChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params baseChannelParams) (any, error) { + return c.client.Channels.Get(ctx, params.TeamRef, params.ChannelRef) + }) +} + +type createChannelParams struct { TeamRef string `json:"teamRef"` + Name string `json:"name"` +} + +func (c *TeamsJSONClient) CreateStandardChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params createChannelParams) (any, error) { + return c.client.Channels.CreateStandardChannel(ctx, params.TeamRef, params.Name) + }) +} + +type createPrivateChannelParams struct { + TeamRef string `json:"teamRef"` + Name string `json:"name"` + MemberRefs []string `json:"memberRefs"` + OwnerRefs []string `json:"ownerRefs"` +} + +func (c *TeamsJSONClient) CreatePrivateChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params createPrivateChannelParams) (any, error) { + return c.client.Channels.CreatePrivateChannel(ctx, params.TeamRef, params.Name, params.MemberRefs, params.OwnerRefs) + }) +} + +func (c *TeamsJSONClient) DeleteChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params baseChannelParams) (any, error) { + err := c.client.Channels.Delete(ctx, params.TeamRef, params.ChannelRef) + return "deleted", err + }) +} + +type sendMessageToChannelParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + Body decoders.MessageBodyDTO `json:"body"` +} + +func (c *TeamsJSONClient) SendMessageToChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params sendMessageToChannelParams) (any, error) { + body, err := decoders.DecodeParams[models.MessageBody](¶ms.Body) + if err != nil { + return nil, err + } + return c.client.Channels.SendMessage(ctx, params.TeamRef, params.ChannelRef, *body) + }) +} + +type sendReplyToChannelParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + MessageID string `json:"messageId"` + Body decoders.MessageBodyDTO `json:"body"` +} + +func (c *TeamsJSONClient) SendReplyToChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params sendReplyToChannelParams) (any, error) { + body, err := decoders.DecodeParams[models.MessageBody](¶ms.Body) + if err != nil { + return nil, err + } + return c.client.Channels.SendReply(ctx, params.TeamRef, params.ChannelRef, params.MessageID, *body) + }) +} + +type listMessagesOptionsDTO struct { + Top *int32 `json:"top"` + ExpandReplies bool `json:"expandReplies"` +} + +type listMessagesParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + Options listMessagesOptionsDTO `json:"options"` + IncludeSystem bool `json:"includeSystem"` + NextLink decoders.NextLinkDTO `json:"nextLink"` +} + +func (c *TeamsJSONClient) ListMessagesInChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params listMessagesParams) (any, error) { + options, err := decoders.DecodeParams[models.ListMessagesOptions](params.Options) + if err != nil { + return nil, err + } + nextLink := decoders.GetNextLink(¶ms.NextLink) + return c.client.Channels.ListMessages(ctx, params.TeamRef, params.ChannelRef, options, params.IncludeSystem, nextLink) + }) +} + +type getMessageParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + MessageID string `json:"messageId"` +} + +func (c *TeamsJSONClient) GetMessageInChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params getMessageParams) (any, error) { + return c.client.Channels.GetMessage(ctx, params.TeamRef, params.ChannelRef, params.MessageID) + }) +} + +type listRepliesParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + MessageID string `json:"messageId"` + Top *int32 `json:"top"` + IncludeSystem bool `json:"includeSystem"` + NextLink decoders.NextLinkDTO `json:"nextLink"` +} + +func (c *TeamsJSONClient) ListMessageRepliesInChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params listRepliesParams) (any, error) { + nextLink := decoders.GetNextLink(¶ms.NextLink) + return c.client.Channels.ListReplies(ctx, params.TeamRef, params.ChannelRef, params.MessageID, params.Top, params.IncludeSystem, nextLink) + }) +} + +type getReplyParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + MessageID string `json:"messageId"` + ReplyID string `json:"replyId"` +} + +func (c *TeamsJSONClient) GetMessageReplyInChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params getReplyParams) (any, error) { + return c.client.Channels.GetReply(ctx, params.TeamRef, params.ChannelRef, params.MessageID, params.ReplyID) + }) +} + +func (c *TeamsJSONClient) ListChannelMembers(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params baseChannelParams) (any, error) { + return c.client.Channels.ListMembers(ctx, params.TeamRef, params.ChannelRef) + }) +} + +type addOrUpdateMemberToChannelParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + UserRef string `json:"userRef"` + IsOwner bool `json:"isOwner"` +} + +func (c *TeamsJSONClient) AddMemberToChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params addOrUpdateMemberToChannelParams) (any, error) { + return c.client.Channels.AddMember(ctx, params.TeamRef, params.ChannelRef, params.UserRef, params.IsOwner) + }) +} + +func (c *TeamsJSONClient) UpdateMemberInChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params addOrUpdateMemberToChannelParams) (any, error) { + return c.client.Channels.UpdateMemberRoles(ctx, params.TeamRef, params.ChannelRef, params.UserRef, params.IsOwner) + }) +} + +type removeMemberFromChannelParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + UserRef string `json:"userRef"` +} + +func (c *TeamsJSONClient) RemoveMemberFromChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params removeMemberFromChannelParams) (any, error) { + err := c.client.Channels.RemoveMember(ctx, params.TeamRef, params.ChannelRef, params.UserRef) + return "removed", err + }) +} + +type getMentionsInChannelParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + RawMentions []string `json:"rawMentions"` +} + +func (c *TeamsJSONClient) GetMentionsInChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params getMentionsInChannelParams) (any, error) { + return c.client.Channels.GetMentions(ctx, params.TeamRef, params.ChannelRef, params.RawMentions) + }) +} + +type searchMessagesInChannelParams struct { + TeamRef string `json:"teamRef"` + ChannelRef string `json:"channelRef"` + SearchOptions decoders.SearchMessageOptionsDTO `json:"searchMessagesOptions"` + SearchConfig decoders.SearchConfigDTO `json:"searchConfig"` } -func (jsonclient *TeamsJSONClient) ListChannels(params map[string]interface{}) (interface{}, error) { - teamRef, ok := params["teamRef"].(string) - if !ok || teamRef == "" { - return nil, fmt.Errorf("invalid teamRef parameter") - } - channels, err := jsonclient.client.Channels.ListChannels(context.TODO(), teamRef) - if err != nil { - return nil, err - } else { - return channels, nil - } +func (c *TeamsJSONClient) SearchMessagesInChannel(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params searchMessagesInChannelParams) (any, error) { + searchOptions := decoders.DecodeSearchMessageOptions(¶ms.SearchOptions) + searchConfig, err := decoders.DecodeParams[search.SearchConfig](¶ms.SearchConfig) + if err != nil { + return nil, err + } + return c.client.Channels.SearchMessages(ctx, ¶ms.TeamRef, ¶ms.ChannelRef, searchOptions, searchConfig) + }) } diff --git a/go/internal/json-client/requests-chats.go b/go/internal/json-client/requests-chats.go new file mode 100644 index 0000000..4708e76 --- /dev/null +++ b/go/internal/json-client/requests-chats.go @@ -0,0 +1,215 @@ +package jsonclient + +import ( + "context" + + "github.com/pzsp-teams/lib-python/internal/json-client/decoders" + "github.com/pzsp-teams/lib/chats" + "github.com/pzsp-teams/lib/models" + "github.com/pzsp-teams/lib/search" +) + +type createOneToOneChatParams struct { + RecipientRef string `json:"recipientRef"` +} + +func (c *TeamsJSONClient) CreateOneToOneChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params createOneToOneChatParams) (any, error) { + return c.client.Chats.CreateOneOnOne(ctx, params.RecipientRef) + }) +} + +type createGroupChatParams struct { + RecipientRefs []string `json:"recipientRefs"` + Topic string `json:"topic"` + IncludeMe bool `json:"includeMe"` +} + +func (c *TeamsJSONClient) CreateGroupChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params createGroupChatParams) (any, error) { + return c.client.Chats.CreateGroup(ctx, params.RecipientRefs, params.Topic, params.IncludeMe) + }) +} + +type baseChatParams struct { + ChatRef decoders.ChatRefDTO `json:"chatRef"` +} + +func (c *TeamsJSONClient) GetChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params baseChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + return c.client.Chats.GetChat(ctx, chatRef) + }) +} + +type groupChatMemberParams struct { + GroupChatRef string `json:"groupChatRef"` + UserRef string `json:"userRef"` +} + +func (c *TeamsJSONClient) AddMemberToGroupChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params groupChatMemberParams) (any, error) { + return c.client.Chats.AddMemberToGroupChat(ctx, chats.GroupChatRef{Ref: params.GroupChatRef}, params.UserRef) + }) +} + +func (c *TeamsJSONClient) RemoveMemberFromGroupChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params groupChatMemberParams) (any, error) { + err := c.client.Chats.RemoveMemberFromGroupChat(ctx, chats.GroupChatRef{Ref: params.GroupChatRef}, params.UserRef) + return "removed", err + }) +} + +type listMembersInChatParams struct { + GroupChatRef string `json:"groupChatRef"` +} + +func (c *TeamsJSONClient) ListGroupChatMembers(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params listMembersInChatParams) (any, error) { + return c.client.Chats.ListGroupChatMembers(ctx, chats.GroupChatRef{Ref: params.GroupChatRef}) + }) +} + +type updateGroupChatTopicParams struct { + GroupChatRef string `json:"groupChatRef"` + Topic string `json:"topic"` +} + +func (c *TeamsJSONClient) UpdateGroupChatTopic(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params updateGroupChatTopicParams) (any, error) { + return c.client.Chats.UpdateGroupChatTopic(ctx, chats.GroupChatRef{Ref: params.GroupChatRef}, params.Topic) + }) +} + +type listMessagesInChatParams struct { + ChatRef decoders.ChatRefDTO `json:"chatRef"` + IncludeSystem bool `json:"includeSystem"` + NextLink decoders.NextLinkDTO `json:"nextLink"` +} + +func (c *TeamsJSONClient) ListMessagesInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params listMessagesInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + nextLink := decoders.GetNextLink(¶ms.NextLink) + return c.client.Chats.ListMessages(ctx, chatRef, params.IncludeSystem, nextLink) + }) +} + +type sendMessageInChatParams struct { + ChatRef decoders.ChatRefDTO `json:"chatRef"` + Body decoders.MessageBodyDTO `json:"body"` +} + +func (c *TeamsJSONClient) SendMessageInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params sendMessageInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + body, err := decoders.DecodeParams[models.MessageBody](params.Body) + if err != nil { + return nil, err + } + return c.client.Chats.SendMessage(ctx, chatRef, *body) + }) +} + +type messageInChatParams struct { + ChatRef decoders.ChatRefDTO `json:"chatRef"` + MessageID string `json:"messageId"` +} + +func (c *TeamsJSONClient) DeleteMessageInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params messageInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + err := c.client.Chats.DeleteMessage(ctx, chatRef, params.MessageID) + return "deleted", err + }) +} + +func (c *TeamsJSONClient) GetMessageInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params messageInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + return c.client.Chats.GetMessage(ctx, chatRef, params.MessageID) + }) +} + +type ListMyChatsParams struct { + ChatType string `json:"chatType"` +} + +func (c *TeamsJSONClient) ListMyChats(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params ListMyChatsParams) (any, error) { + var chatType models.ChatType + if params.ChatType != "" { + chatType = models.ChatType(params.ChatType) + } + return c.client.Chats.ListChats(ctx, &chatType) + }) +} + +type listChatMessagesParams struct { + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Top *int32 `json:"top"` +} + +func (c *TeamsJSONClient) ListMyChatMessages(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params listChatMessagesParams) (any, error) { + parsedStartTime, parsedEndTime, err := decoders.DecodeTimeRange(params.StartTime, params.EndTime) + if err != nil { + return nil, err + } + return c.client.Chats.ListAllMessages(ctx, parsedStartTime, parsedEndTime, params.Top) + }) +} + +func (c *TeamsJSONClient) ListPinnedMessagesInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params baseChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + return c.client.Chats.ListPinnedMessages(ctx, chatRef) + }) +} + +func (c *TeamsJSONClient) PinMessageInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params messageInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + err := c.client.Chats.PinMessage(ctx, chatRef, params.MessageID) + return "pinned", err + }) +} + +func (c *TeamsJSONClient) UnpinMessageInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params messageInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + err := c.client.Chats.UnpinMessage(ctx, chatRef, params.MessageID) + return "unpinned", err + }) +} + +type mentionInChatParams struct { + ChatRef decoders.ChatRefDTO `json:"chatRef"` + RawMentions []string `json:"rawMentions"` +} + +func (c *TeamsJSONClient) GetMentionsInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params mentionInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + return c.client.Chats.GetMentions(ctx, chatRef, params.RawMentions) + }) +} + +type searchMessagesInChatParams struct { + ChatRef decoders.ChatRefDTO `json:"chatRef"` + SearchOptions decoders.SearchMessageOptionsDTO `json:"searchMessagesOptions"` + SearchConfig decoders.SearchConfigDTO `json:"searchConfig"` +} + +func (c *TeamsJSONClient) SearchMessagesInChat(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params searchMessagesInChatParams) (any, error) { + chatRef := decoders.GetChatRef(params.ChatRef) + searchOptions := decoders.DecodeSearchMessageOptions(¶ms.SearchOptions) + searchConfig, err := decoders.DecodeParams[search.SearchConfig](¶ms.SearchConfig) + if err != nil { + return nil, err + } + return c.client.Chats.SearchMessages(ctx, chatRef, searchOptions, searchConfig) + }) +} diff --git a/go/internal/json-client/requests-teams.go b/go/internal/json-client/requests-teams.go new file mode 100644 index 0000000..f8608a8 --- /dev/null +++ b/go/internal/json-client/requests-teams.go @@ -0,0 +1,104 @@ +package jsonclient + +import ( + "context" + + "github.com/pzsp-teams/lib-python/internal/json-client/decoders" +) + +type getTeamParams struct { + TeamRef string `json:"teamRef"` +} + +func (c *TeamsJSONClient) GetTeam(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params getTeamParams) (any, error) { + return c.client.Teams.Get(ctx, params.TeamRef) + }) +} + +func (c *TeamsJSONClient) ListMyJoined(ctx context.Context) (any, error) { + return c.client.Teams.ListMyJoined(ctx) +} + +type updateTeamParams struct { + TeamRef string `json:"teamRef"` + TeamUpdate decoders.UpdateTeamDTO `json:"team"` +} + +func (c *TeamsJSONClient) UpdateTeam(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params updateTeamParams) (any, error) { + updateTeam := decoders.GetUpdateTeam(¶ms.TeamUpdate) + return c.client.Teams.UpdateTeam(ctx, params.TeamRef, &updateTeam) + }) +} + +type createViaGroupParams struct { + DisplayName string `json:"displayName"` + MailNickname string `json:"mailNickname"` + Visibility string `json:"visibility"` +} + +func (c *TeamsJSONClient) CreateTeamViaGroup(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params createViaGroupParams) (any, error) { + return c.client.Teams.CreateViaGroup(ctx, params.DisplayName, params.MailNickname, params.Visibility) + }) +} + +type createFromTemplateParams struct { + DisplayName string `json:"displayName"` + Description string `json:"description"` + Owners []string `json:"owners"` + Members []string `json:"members"` + Visibility string `json:"visibility"` + IncludeMe bool `json:"includeMe"` +} + +func (c *TeamsJSONClient) CreateTeamFromTemplate(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params createFromTemplateParams) (any, error) { + return c.client.Teams.CreateFromTemplate(ctx, params.DisplayName, params.Description, params.Owners, params.Members, params.Visibility, params.IncludeMe) + }) +} + +type archiveTeamParams struct { + TeamRef string `json:"teamRef"` + SpoReadOnlyFromMembers *bool `json:"spoReadOnlyFromMembers,omitempty"` +} + +func (c *TeamsJSONClient) ArchiveTeam(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params archiveTeamParams) (any, error) { + err := c.client.Teams.Archive(ctx, params.TeamRef, params.SpoReadOnlyFromMembers) + return "archived", err + }) +} + +type unarchiveTeamParams struct { + TeamRef string `json:"teamRef"` +} + +func (c *TeamsJSONClient) UnarchiveTeam(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params unarchiveTeamParams) (any, error) { + err := c.client.Teams.Unarchive(ctx, params.TeamRef) + return "unarchived", err + }) +} + +type deleteTeamParams struct { + TeamRef string `json:"teamRef"` +} + +func (c *TeamsJSONClient) DeleteTeam(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params deleteTeamParams) (any, error) { + err := c.client.Teams.Delete(ctx, params.TeamRef) + return "deleted", err + }) +} + +type restoreTeamParams struct { + DeletedGroupID string `json:"deletedGroupId"` +} + +func (c *TeamsJSONClient) RestoreDeletedTeam(ctx context.Context, p map[string]any) (any, error) { + return execute(p, func(params restoreTeamParams) (any, error) { + return c.client.Teams.RestoreDeleted(ctx, params.DeletedGroupID) + }) +} diff --git a/go/internal/json-client/teams-json-client.go b/go/internal/json-client/teams-json-client.go index afe9430..7ef7360 100644 --- a/go/internal/json-client/teams-json-client.go +++ b/go/internal/json-client/teams-json-client.go @@ -1,9 +1,19 @@ package jsonclient import ( - lib "github.com/pzsp-teams/lib" + "github.com/pzsp-teams/lib" + + "github.com/pzsp-teams/lib-python/internal/json-client/decoders" ) type TeamsJSONClient struct { client *lib.Client } + +func execute[T any](p map[string]any, fn func(params T) (any, error)) (any, error) { + params, err := decoders.DecodeParams[T](p) + if err != nil { + return nil, err + } + return fn(*params) +} diff --git a/go/internal/json-model/json-model.go b/go/internal/json-model/json-model.go index 3d76018..05f24bc 100644 --- a/go/internal/json-model/json-model.go +++ b/go/internal/json-model/json-model.go @@ -3,20 +3,20 @@ package jsonmodel import ( "fmt" - "github.com/pzsp-teams/lib" + "github.com/pzsp-teams/lib/config" ) type Request struct { Type string `json:"type"` Method string `json:"method,omitempty"` Config Config `json:"config,omitempty"` - Params map[string]interface{} `json:"params,omitempty"` + Params map[string]any `json:"params,omitempty"` } type Config struct { SenderConfigMap SenderConfigMap `json:"senderConfig"` AuthConfigMap AuthConfigMap `json:"authConfig"` - CacheEnabled bool `json:"cacheEnabled"` + CacheMode string `json:"cacheMode,omitempty"` CachePath string `json:"cachePath,omitempty"` } @@ -26,7 +26,7 @@ type SenderConfigMap struct { Timeout int `json:"timeout"` } -func (scm SenderConfigMap) ToSenderConfig() (*lib.SenderConfig, error) { +func (scm SenderConfigMap) ToSenderConfig() (*config.SenderConfig, error) { if scm.MaxRetries < 0 { return nil, fmt.Errorf("maxRetries cannot be negative") } @@ -37,7 +37,7 @@ func (scm SenderConfigMap) ToSenderConfig() (*lib.SenderConfig, error) { return nil, fmt.Errorf("timeout must be > 0") } - return &lib.SenderConfig{ + return &config.SenderConfig{ MaxRetries: scm.MaxRetries, NextRetryDelay: scm.NextRetryDelay, Timeout: scm.Timeout, @@ -52,7 +52,7 @@ type AuthConfigMap struct { AuthMethod string `json:"authMethod"` } -func (acm AuthConfigMap) ToAuthConfig() (*lib.AuthConfig, error) { +func (acm AuthConfigMap) ToAuthConfig() (*config.AuthConfig, error) { if acm.ClientID == "" { return nil, fmt.Errorf("clientId is required") } @@ -74,33 +74,34 @@ func (acm AuthConfigMap) ToAuthConfig() (*lib.AuthConfig, error) { return nil, err } - return &lib.AuthConfig{ + return &config.AuthConfig{ ClientID: acm.ClientID, Tenant: acm.Tenant, Email: acm.Email, Scopes: acm.Scopes, - AuthMethod: authMethod, + AuthMethod: config.Method(authMethod), }, nil } -func ParseCacheEnabled(value interface{}) (bool, error) { - if value == nil { - return false, nil // default - } - - enabled, ok := value.(bool) - if !ok { - return false, fmt.Errorf("cacheEnabled must be boolean") - } - return enabled, nil +func ParseCachePath(value any) (*string, error) { + path, ok := value.(string) + if !ok || path == "" { + return nil, fmt.Errorf("cachePath must be a non-empty string") + } + return &path, nil } -func ParseCachePath(value interface{}) (*string, error) { - path, ok := value.(string) - if !ok || path == "" { - return nil, fmt.Errorf("cachePath must be a non-empty string") - } - return &path, nil +func ParseCacheMode(value any) (config.CacheMode, error) { + switch value { + case "DISABLED": + return config.CacheDisabled, nil + case "SYNC": + return config.CacheSync, nil + case "ASYNC": + return config.CacheAsync, nil + default: + return config.CacheDisabled, fmt.Errorf("invalid cacheMode: %s", value) + } } func validateAuthMethod(method string) (string, error) { diff --git a/python/.env.template b/python/.env.template new file mode 100644 index 0000000..e1fca19 --- /dev/null +++ b/python/.env.template @@ -0,0 +1,5 @@ +CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxx +TENANT_ID=xxxxxxxxxxxxxxxxxxxxxxxxx +EMAIL=xxxxxxxxxxxxxxxxxxxxxxxxx +SCOPES=openid,profile,User.Read.All,Team.ReadBasic.All,Channel.ReadBasic.All,ChannelSettings.ReadWrite.All,Channel.Create,Channel.Delete.All,ChannelMessage.Send,ChannelMember.ReadWrite.All,Group.ReadWrite.All,TeamMember.ReadWrite.All +AUTH_METHOD=INTERACTIVE \ No newline at end of file diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..45ddf0a --- /dev/null +++ b/python/.gitignore @@ -0,0 +1 @@ +site/ diff --git a/python/README.md b/python/README.md index e69de29..1ccd0ae 100644 --- a/python/README.md +++ b/python/README.md @@ -0,0 +1,120 @@ +# Teams API Python Wrapper + +[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Documentation](https://img.shields.io/badge/docs-online-green)](https://pzsp-teams.github.io/lib-python/) + +
+ +A high-performance **Python wrapper** for the Microsoft Teams Graph API. It leverages a compiled **Go (Golang)** backend to handle heavy lifting, concurrency, and caching, while providing a clean, fully typed Pythonic interface. + +## 🚀 Key Features + +- **Performance of Go**: Uses a compiled Go subprocess for efficient API communication and state management. +- **Pythonic Interface**: Fully typed using `dataclasses` and `Enums`. Native support for IDE autocompletion and type checkers (`mypy`). +- **Intelligent Cache**: Background caching of Team/Channel IDs to minimize API throttling and latency (handled transparently by the backend). +- **Simplified Auth**: Wraps MSAL authentication flows configuration. + +## 📦 Installation + +```bash +pip install teams-lib-pzsp2-z1 +``` + +## 🛠️ Architecture + +This library acts as a bridge. When initialized, it spawns a dedicated Go subprocess (`teamsClientLib`) and communicates via JSON-RPC over `stdin`/`stdout`. + +The `TeamsClient` aggregates domain-specific services: + +- **client.teams**: Manage team lifecycles (create, list, delete). + +- **client.channels**: Manage standard and private channels. + +- **client.chats**: Handle messages, mentions, and chat participants. + +## 💻 Quick Start + +### 1. Configuration + +Create a `.env` in your project with your Azure AD credentials: + +```ini +CLIENT_ID=your-client-id-uuid +TENANT_ID=your-tenant-id-uuid +EMAIL=user@example.com +SCOPES=User.Read,Team.ReadBasic.All,Channel.ReadBasic.All +AUTH_METHOD=DEVICE_CODE [or INTERACTIVE] +``` + +Scopes needed by all functions could be found in [.env.template](https://github.com/pzsp-teams/lib-python/tree/main/.env.template). + +### 2. Basic usage + +Here is a minimal example showing how to initialize the client and list joined teams: + +```python +from teams_lib_pzsp2_z1.client import TeamsClient +from teams_lib_pzsp2_z1.config import CacheMode + +def main(): + # Initialize the client. + # This spawns the Go subprocess and performs authentication. + client = TeamsClient() + + try: + print("Fetching teams...") + teams = client.teams.list_my_joined() + + for team in teams: + print(f"Team: {team.display_name} | ID: {team.id}") + + # Example: List channels in the first team + channels = client.channels.list_channels(team.id) + print(f" -> Found {len(channels)} channels.") + + except RuntimeError as e: + print(f"An error occurred: {e}") + + finally: + # Crucial: Close the client to ensure background cache writes finish + # and the Go subprocess is terminated gracefully. + client.close() + +if __name__ == "__main__": + main() +``` + +## 📚 Documentation + +Full API reference, architecture details, and configuration guides are available here: + +👉 [Read the Documentation](https://pzsp-teams.github.io/lib-python/) + +## Authentication + +The library loads credentials from environment variables. Ensure your Azure App Registration has the necessary **API Permissions** (e.g., `Team.ReadBasic.All`, `Channel.ReadBasic.All`) granted in the Azure Portal. + +Supported Authentication Methods (`AUTH_METHOD`): + +- **INTERACTIVE**: Opens a browser window automatically. + +- **DEVICE_CODE**: Prints a code to the console to login via microsoft.com/devicelogin. + +## Cache & Lifecycle + +If caching is enabled (SYNC or ASYNC), the Go backend stores metadata locally (e.g., `teams_cache.json`) to speed up reference resolution. + +⚠️ Important: client.close() +Because the Go backend might be running background goroutines (especially for Async Cache), you **must** call `client.close()` before your script exits. This ensures: + +1. Pending cache writes are flushed to disk. + +2. The Go subprocess is terminated correctly. + +```python +client.close() +``` + +## 📄 License +This project is licensed under the MIT License - see the [LICENSE](https://github.com/pzsp-teams/lib-python/blob/main/LICENSE) file for details. diff --git a/python/docs/index.md b/python/docs/index.md new file mode 100644 index 0000000..dd570f2 --- /dev/null +++ b/python/docs/index.md @@ -0,0 +1,128 @@ +# Teams Lib Wrapper + +Welcome to the documentation for the **Teams API library**. +This library provides a high-level, Pythonic interface for managing Microsoft Teams, leveraging a compiled Go backend for performance, authentication, and caching. + +--- + +## Overview + +This project bridges the gap between Python's ease of use and Go's concurrency model. +It acts as a wrapper around a Go binary (`teamsClientLib`), communicating via a JSON-based IPC (Inter-Process Communication) protocol. + +--- + +## Key Features + +- **Pythonic Data Models** + Uses standard Python `dataclasses` and `Enum`s instead of raw JSON or complex Microsoft Graph SDK objects. + +- **Automatic Caching** + Reduces API calls by caching Team and Channel references (handled transparently by the Go backend). + +- **Simple Authentication** + Thin wrappers around MSAL (Microsoft Authentication Library) configuration. + +- **Type Hinting** + Full support for static type checkers like `mypy` and IDE autocompletion. + +--- + +## Installation + +Library is available fully via pip + +```bash +pip install teams_lib_pzsp2_z1 +``` + +## Quick start + +### 1. Configuration + +Create a `.env` in your project with your Azure AD credentials: + +```ini +CLIENT_ID=your-client-id-uuid +TENANT_ID=your-tenant-id-uuid +EMAIL=user@example.com +SCOPES=User.Read,Team.ReadBasic.All,Channel.ReadBasic.All +AUTH_METHOD=DEVICE_CODE [or INTERACTIVE] +``` + +Scopes needed by all functions could be found in [.env.template](https://github.com/pzsp-teams/lib-python/tree/main/.env.template). + +### 2. Basic usage + +Here is a minimal example showing how to initialize the client and list joined teams: + +```python +from teams_lib_pzsp2_z1.client import TeamsClient +from teams_lib_pzsp2_z1.config import CacheMode + +def main(): + # Initialize the client. + # This spawns the Go subprocess and performs authentication. + client = TeamsClient() + + try: + print("Fetching teams...") + teams = client.teams.list_my_joined() + + for team in teams: + print(f"Team: {team.display_name} | ID: {team.id}") + + # Example: List channels in the first team + channels = client.channels.list_channels(team.id) + print(f" -> Found {len(channels)} channels.") + + except RuntimeError as e: + print(f"An error occurred: {e}") + + finally: + # Crucial: Close the client to ensure background cache writes finish + # and the Go subprocess is terminated gracefully. + client.close() + +if __name__ == "__main__": + main() +``` + +## Architecture + +The library relies on a compiled Go backend to handle performance-critical tasks. When `TeamsClient` is initialized, the following sequence occurs: + +1. **OS Detection**: The library checks the host operating system (Windows/Linux). +2. **Process Launch**: It launches the corresponding Go binary as a background subprocess. +3. **Initialization**: It sends an `init` command via `stdin` to configure the MSAL authentication provider and the caching engine. +4. **Request Handling**: Subsequent method calls (e.g., `client.teams.get(...)`) follow this flow: + - Arguments are **serialized to JSON**. + - The payload is sent to the Go process via **stdin**. + - The Go process executes the request against the **Microsoft Graph API**. + - The JSON response is read from **stdout** and mapped back to native **Python objects**. + +### Process Flow Diagram + +```mermaid +sequenceDiagram + participant Py as Python Client + participant Go as Go Subprocess + participant MS as Microsoft Graph + + rect rgba(230, 240, 255, 0.7) + note right of Py: Phase 1: Initialization + Py->>Py: Detect OS & Spawn Process + Py->>Go: Send {"type": "init", ...} + Go->>Go: Setup MSAL & Cache + Go-->>Py: {"result": "initialized"} + end + + rect rgba(240, 255, 240, 0.7) + note right of Py: Phase 2: Normal Operation Loop + Py->>Go: Send {"type": "request", "method": "getTeam", ...} + Go->>MS: GET /v1.0/teams/{id} + MS-->>Go: HTTP 200 OK (JSON) + Go-->>Py: {"result": {...}} + Py->>Py: Map JSON to Python DataClass + end +``` diff --git a/python/docs/reference/client.md b/python/docs/reference/client.md new file mode 100644 index 0000000..00d7b9d --- /dev/null +++ b/python/docs/reference/client.md @@ -0,0 +1,8 @@ +# Client Reference + +Main lib entry point. + +::: teams_lib_pzsp2_z1.client.TeamsClient + options: + show_root_heading: true + show_source: true \ No newline at end of file diff --git a/python/docs/reference/config.md b/python/docs/reference/config.md new file mode 100644 index 0000000..b6ccc47 --- /dev/null +++ b/python/docs/reference/config.md @@ -0,0 +1,5 @@ +# Configuration Reference + +Config structures. + +::: teams_lib_pzsp2_z1.config \ No newline at end of file diff --git a/python/docs/reference/models/channel.md b/python/docs/reference/models/channel.md new file mode 100644 index 0000000..8346b97 --- /dev/null +++ b/python/docs/reference/models/channel.md @@ -0,0 +1,3 @@ +# Channel Model + +::: teams_lib_pzsp2_z1.model.channel \ No newline at end of file diff --git a/python/docs/reference/models/chat.md b/python/docs/reference/models/chat.md new file mode 100644 index 0000000..b4eb115 --- /dev/null +++ b/python/docs/reference/models/chat.md @@ -0,0 +1,3 @@ +# Chat Model + +::: teams_lib_pzsp2_z1.model.chat \ No newline at end of file diff --git a/python/docs/reference/models/member.md b/python/docs/reference/models/member.md new file mode 100644 index 0000000..6056a2c --- /dev/null +++ b/python/docs/reference/models/member.md @@ -0,0 +1,3 @@ +# Member Model + +::: teams_lib_pzsp2_z1.model.member \ No newline at end of file diff --git a/python/docs/reference/models/message.md b/python/docs/reference/models/message.md new file mode 100644 index 0000000..90ec708 --- /dev/null +++ b/python/docs/reference/models/message.md @@ -0,0 +1,5 @@ +# Message Models + +::: teams_lib_pzsp2_z1.model.message + +::: teams_lib_pzsp2_z1.model.mention \ No newline at end of file diff --git a/python/docs/reference/models/search.md b/python/docs/reference/models/search.md new file mode 100644 index 0000000..306e71a --- /dev/null +++ b/python/docs/reference/models/search.md @@ -0,0 +1,3 @@ +# Search messages model + +::: teams_lib_pzsp2_z1.model.search \ No newline at end of file diff --git a/python/docs/reference/models/team.md b/python/docs/reference/models/team.md new file mode 100644 index 0000000..b1176db --- /dev/null +++ b/python/docs/reference/models/team.md @@ -0,0 +1,3 @@ +# Team Model + +::: teams_lib_pzsp2_z1.model.team \ No newline at end of file diff --git a/python/docs/reference/services/channels.md b/python/docs/reference/services/channels.md new file mode 100644 index 0000000..a0748e0 --- /dev/null +++ b/python/docs/reference/services/channels.md @@ -0,0 +1,5 @@ +# Channels Service + +Channels in team management. + +::: teams_lib_pzsp2_z1.services.channels.ChannelsService \ No newline at end of file diff --git a/python/docs/reference/services/chats.md b/python/docs/reference/services/chats.md new file mode 100644 index 0000000..de4182a --- /dev/null +++ b/python/docs/reference/services/chats.md @@ -0,0 +1,5 @@ +# Chats Service + +Handling one-to-one and group chats. + +::: teams_lib_pzsp2_z1.services.chats.ChatsService \ No newline at end of file diff --git a/python/docs/reference/services/teams.md b/python/docs/reference/services/teams.md new file mode 100644 index 0000000..d62308d --- /dev/null +++ b/python/docs/reference/services/teams.md @@ -0,0 +1,5 @@ +# Teams Service + +Team lifecycle management. + +::: teams_lib_pzsp2_z1.services.teams.TeamsService \ No newline at end of file diff --git a/python/mkdocs.yml b/python/mkdocs.yml new file mode 100644 index 0000000..a861f32 --- /dev/null +++ b/python/mkdocs.yml @@ -0,0 +1,60 @@ +site_name: Teams Lib Wrapper Documentation +site_url: https://pzsp2z1teams.com/ +repo_url: https://github.com/pzsp-teams/lib-python +edit_uri: edit/main/docs/ + +theme: + name: material + features: + - content.code.copy + - navigation.expand + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [.] + options: + docstring_style: google + show_source: true + show_root_heading: true + show_category_heading: true + +markdown_extensions: + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + +nav: + - Home: index.md + - API Reference: + - Client: reference/client.md + - Config: reference/config.md + - Services: + - Teams: reference/services/teams.md + - Channels: reference/services/channels.md + - Chats: reference/services/chats.md + - Models: + - Team: reference/models/team.md + - Channel: reference/models/channel.md + - Message: reference/models/message.md + - Member: reference/models/member.md + - Chat: reference/models/chat.md + - Search: reference/models/search.md \ No newline at end of file diff --git a/python/pyproject.toml b/python/pyproject.toml index fcf29b1..d709581 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -23,6 +23,7 @@ fix = true show-fixes = true src = ["teams_lib_pzsp2_z1"] exclude = ["test_*.py", "tests/*"] +ignore = ["E501"] [tool.ruff.lint] diff --git a/python/teams_lib_pzsp2_z1/client.py b/python/teams_lib_pzsp2_z1/client.py index 055ac45..b2f6ec1 100644 --- a/python/teams_lib_pzsp2_z1/client.py +++ b/python/teams_lib_pzsp2_z1/client.py @@ -2,39 +2,93 @@ import pathlib import platform import subprocess +import sys import threading from typing import Any from teams_lib_pzsp2_z1 import config from teams_lib_pzsp2_z1.services.channels import ChannelsService +from teams_lib_pzsp2_z1.services.chats import ChatsService +from teams_lib_pzsp2_z1.services.teams import TeamsService class TeamsClient: + """The main entry point for interacting with the Microsoft Teams library. + + This class manages the lifecycle of the underlying Go subprocess, handles + Inter-Process Communication (IPC) via JSON over stdin/stdout, and exposes + high-level services for Teams, Channels, and Chats. + + **Architecture:** + The Python library acts as a frontend wrapper. It spawns a compiled Go binary + (`teamsClientLib`) as a subprocess. Commands are serialized to JSON and sent + to the Go process, which executes the actual Microsoft Graph API calls and + returns the results. + + Attributes: + channels (ChannelsService): Service for managing channels. + teams (TeamsService): Service for managing teams. + chats (ChatsService): Service for managing chats and messages. + """ + + def __init__( self, auto_init: bool = True, env_path: str | None = None, - cache_enabled: bool = False, + cache_mode: config.CacheMode = config.CacheMode.DISABLED, cache_path: str | None = None, ): + """Initializes the TeamsClient and spawns the Go subprocess. + + Args: + auto_init (bool, optional): If True, automatically calls `init_client` + using configuration loaded from the environment. Defaults to True. + env_path (str | None, optional): Path to the `.env` file containing + credentials (CLIENT_ID, TENANT_ID, etc.). If None, defaults are used. + cache_mode (config.CacheMode, optional): The caching strategy to use + (e.g., DISABLED, IN_MEMORY, DISK). Defaults to DISABLED. + cache_path (str | None, optional): File path for the cache (required if + cache_mode is DISK). Defaults to None. + + Raises: + RuntimeError: If the operating system is not supported (only Windows/Linux). + """ + self._lock = threading.Lock() + # Spawn the Go binary in a subprocess with piped IO self.proc = subprocess.Popen( # noqa: S603 [str(self._binary())], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=sys.stderr, # Pass Go logs directly to stderr text=True, bufsize=1, ) self.env_path = env_path + + # Initialize services with a reference to self (to access .execute()) self.channels = ChannelsService(self) + self.teams = TeamsService(self) + self.chats = ChatsService(self) + + self.is_cache_enabled = cache_mode != config.CacheMode.DISABLED if auto_init: - self.init_client(cache_enabled, cache_path) + self.init_client(cache_mode, cache_path) def _binary(self): + """Resolves the path to the correct Go binary for the current OS. + + Returns: + pathlib.Path: The absolute path to the executable. + + Raises: + RuntimeError: If the OS is not Windows or Linux. + """ + base = pathlib.Path(__file__).parent / "bin" osname = platform.system() @@ -46,8 +100,26 @@ def _binary(self): raise RuntimeError("Unsupported OS") def init_client( - self, cache_enabled: bool = False, cache_path: str | None = None + self, + cache_mode: config.CacheMode = config.CacheMode.DISABLED, + cache_path: str | None = None, ) -> Any: + """Initializes the Go backend with authentication and cache configuration. + + This method sends the 'init' command to the Go process, which sets up the + MSAL token provider and the Graph Service Client. + + Args: + cache_mode (config.CacheMode): The caching strategy. + cache_path (str | None): Path to the cache file (if applicable). + + Returns: + Any: The initialization result from the Go process. + + Raises: + RuntimeError: If the Go process reports an initialization error. + """ + sender_config = config.SenderConfig() auth_config = config.load_auth_config(self.env_path) return self.execute( @@ -65,12 +137,24 @@ def init_client( "scopes": auth_config.scopes, "authMethod": auth_config.auth_method, }, - "cacheEnabled": cache_enabled, + "cacheMode": cache_mode.value, "cachePath": cache_path, }, ) def init_fake_client(self, mock_server_url: str) -> Any: + """Initializes the Go backend in test mode using a mock server. + + This bypasses real MSAL authentication and directs Graph API calls to + the provided local URL. [For testing purposes only] + + Args: + mock_server_url (str): The URL of the mock HTTP server. + + Returns: + Any: The result of the initialization. + """ + return self.execute( cmd_type="init", params={ @@ -85,6 +169,27 @@ def execute( config: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> Any: + """Executes a command on the Go subprocess via JSON-IPC. + + This is the low-level bridge method used by services to communicate with the backend. + It handles serialization, thread-safe writing to stdin, reading from stdout, + and error propagation. + + Args: + cmd_type (str): The type of command (e.g., "init", "request"). + method (str | None): The specific API method to call (e.g., "listChannels"). + Required if cmd_type is "request". + config (dict | None): Configuration payload (used primarily for initialization). + params (dict | None): Parameters for the method call (e.g., teamRef, body). + + Returns: + Any: The 'result' field from the Go response. + + Raises: + RuntimeError: If the Go process crashes, closes the pipe, returns an empty response, + or explicitly reports an error in the "error" field. + """ + payload = {"type": cmd_type} if method: payload["method"] = method @@ -95,13 +200,19 @@ def execute( json_payload = json.dumps(payload) - # Critical section to avoid interleaving requests/responses + # Critical section to avoid interleaving requests/responses# + # (e.g., thread A writes request A, thread B writes request B, + # then thread A reads response B). with self._lock: try: self.proc.stdin.write(json_payload + "\n") self.proc.stdin.flush() + print("Sent to Go process:", json_payload) # Debug print + raw_response = self.proc.stdout.readline() + + print("Received from Go process:", raw_response.strip()) # Debug print except BrokenPipeError: raise RuntimeError("Go process crashed or closed connection") # noqa: B904 @@ -116,4 +227,35 @@ def execute( return res.get("result") def close(self): - self.proc.terminate() + """Gracefully closes the Go backend, ensuring cache is synced. + + Sends a 'close' command to the Go process, which triggers `lib.Close()` + to wait for background cache operations. Then terminates the process. + """ + + if self.is_cache_enabled: + with self._lock: + if self.proc.poll() is None: + try: + payload = json.dumps({"type": "close"}) + "\n" + self.proc.stdin.write(payload) + self.proc.stdin.flush() + except (BrokenPipeError, OSError): + pass + + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait() + + try: + if self.proc.stdin: + self.proc.stdin.close() + if self.proc.stdout: + self.proc.stdout.close() + except (OSError, ValueError): + pass + else: + self.proc.terminate() + self.proc.wait() diff --git a/python/teams_lib_pzsp2_z1/config.py b/python/teams_lib_pzsp2_z1/config.py index 286aa89..fa6f40f 100644 --- a/python/teams_lib_pzsp2_z1/config.py +++ b/python/teams_lib_pzsp2_z1/config.py @@ -1,15 +1,36 @@ +""" +Package config holds configuration structures used across the application. + +This module defines data classes for authentication, request sending, and caching, +as well as utility functions to load and validate these configurations from +environment variables. +""" + import os from dataclasses import dataclass +from enum import Enum from dotenv import load_dotenv class AuthConfigurationError(Exception): + """Raised when the authentication configuration is missing or invalid.""" pass @dataclass class SenderConfig: + """Defines configuration for the HTTP request sender connecting to Microsoft Graph API. + + Attributes: + max_retries (int): The maximum number of retry attempts for failed requests. + Defaults to 3. + next_retry_delay (int): The delay between retry attempts in **seconds**. + Defaults to 2. + timeout (int): The maximum time to wait for a request to complete in **seconds**. + Defaults to 10. + """ + max_retries: int = 3 next_retry_delay: int = 2 timeout: int = 10 @@ -17,6 +38,21 @@ class SenderConfig: @dataclass class AuthConfig: + """Holds configuration required for authentication via MSAL. + + All fields are required to successfully acquire tokens. + + Attributes: + client_id (str): The Application (client) ID assigned by the Azure portal. + tenant (str): The Directory (tenant) ID or domain. + email (str): The email address of the user to authenticate. + scopes (list[str]): A list of Graph API permission scopes (e.g., "User.Read"). + auth_method (str): The flow used to acquire tokens. + Valid values are: + * **"DEVICE_CODE"**: Prints a code to console; user visits a URL. + * **"INTERACTIVE"**: Opens a local browser window. + """ + client_id: str tenant: str email: str @@ -24,7 +60,55 @@ class AuthConfig: auth_method: str +class CacheMode(Enum): + """Defines the caching strategy used by the application. + + Attributes: + DISABLED: Caching is turned off completely. + SYNC: Cache operations (read/write) are performed synchronously. + Safe but may block the main thread. + ASYNC: Cache operations are performed asynchronously in the background. + Faster for the main thread but requires `close()` to ensure data consistency. + """ + + DISABLED = "DISABLED" + SYNC = "SYNC" + ASYNC = "ASYNC" + +@dataclass +class CacheConfig: + """Holds configuration for the caching layer. + + Attributes: + mode (CacheMode): The caching strategy to use. + path (str | None): The file path for the cache storage (e.g., JSON file). + Required if mode is not DISABLED. Defaults to None. + """ + + mode: CacheMode + path: str | None = None + def load_auth_config(env_path: str | None = None) -> AuthConfig: + """Loads authentication configuration from environment variables or a .env file. + + It looks for the following environment variables: + * `CLIENT_ID` + * `TENANT_ID` + * `EMAIL` + * `SCOPES` (comma-separated, defaults to "https://graph.microsoft.com/.default") + * `AUTH_METHOD` (defaults to "DEVICE_CODE") + + Args: + env_path (str | None, optional): Path to the .env file. If None, + loads from the current directory. + + Returns: + AuthConfig: A populated and validated configuration object. + + Raises: + AuthConfigurationError: If required variables are missing or values are invalid. + """ + load_dotenv(env_path) cfg = AuthConfig( @@ -40,10 +124,24 @@ def load_auth_config(env_path: str | None = None) -> AuthConfig: def get_env(key: str, fallback: str) -> str: + """Retrieves an environment variable with a fallback value.""" + return os.getenv(key, fallback) def validate(cfg: AuthConfig): + """Validates the authentication configuration object. + + Checks for the presence of required fields and the validity of the auth method. + + Args: + cfg (AuthConfig): The configuration object to validate. + + Raises: + AuthConfigurationError: If `client_id`, `tenant`, or `email` are empty, + or if `auth_method` is not supported. + """ + missing = [] if not cfg.client_id: missing.append("CLIENT_ID") diff --git a/python/teams_lib_pzsp2_z1/model/__init__.py b/python/teams_lib_pzsp2_z1/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/teams_lib_pzsp2_z1/model/channel.py b/python/teams_lib_pzsp2_z1/model/channel.py index ace8276..32959ce 100644 --- a/python/teams_lib_pzsp2_z1/model/channel.py +++ b/python/teams_lib_pzsp2_z1/model/channel.py @@ -3,6 +3,18 @@ @dataclass class Channel: - ID: str - Name: str - IsGeneral: bool + """Represents a Microsoft Teams channel. + + This model serves as a data container for channel properties returned + by the underlying Go library. + + Attributes: + id (str): The unique identifier of the channel. + name (str): The display name of the channel (e.g., "General", "Development"). + is_general (bool): Indicates whether this is the default 'General' channel. + The General channel typically cannot be deleted or renamed. + """ + + id: str + name: str + is_general: bool diff --git a/python/teams_lib_pzsp2_z1/model/chat.py b/python/teams_lib_pzsp2_z1/model/chat.py new file mode 100644 index 0000000..fe99164 --- /dev/null +++ b/python/teams_lib_pzsp2_z1/model/chat.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from enum import Enum + + +class ChatType(Enum): + """Represents the type of chat in Microsoft Teams. + + Attributes: + ONE_ON_ONE: Represents a direct conversation between two users. + GROUP: Represents a conversation with multiple participants. + """ + + ONE_ON_ONE = "one-on-one" + GROUP = "group" + + +@dataclass +class Chat: + """Represents a chat in Microsoft Teams. + + Attributes: + id (str): The unique identifier of the chat. + chat_type (ChatType): The type of the conversation (One-on-One or Group). + is_hidden (bool): Indicates whether the chat is hidden for the user. + topic (Optional[str]): The subject or topic of the chat. + This is typically None for one-on-one chats. + """ + + id: str + type: ChatType + is_hidden: bool + topic: str | None + + +@dataclass +class ChatRef: + """Represents a polymorphic reference to a chat used for API lookups. + + This class corresponds to `GroupChatRef` and `OneOnOneChatRef` in the Go library. + The interpretation of the `ref` field depends on the `chat_type`. + + Attributes: + ref (str): The identifier string. + * If **chat_type is GROUP**: Can be a unique Chat ID or a Chat Topic. + **Note**: Using a topic may lead to ambiguities if multiple group chats + share the same name. + * If **chat_type is ONE_ON_ONE**: Can be a unique Chat ID or the + Recipient's reference (User ID or Email). + **Note**: For resolution by User ID/Email to work, the chat must + already exist between the logged-in user and the recipient. + chat_type (ChatType): Determines how the `ref` string is interpreted. + """ + ref: str + type: ChatType diff --git a/python/teams_lib_pzsp2_z1/model/member.py b/python/teams_lib_pzsp2_z1/model/member.py new file mode 100644 index 0000000..09b721b --- /dev/null +++ b/python/teams_lib_pzsp2_z1/model/member.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass +class Member: + """Represents a member of a Microsoft Teams channel, direct chat, or team. + + Attributes: + id (str): The unique identifier of the membership object itself. + **Note:** This is distinct from the `user_id`. + user_id (str): The unique Azure Active Directory (AAD) identifier of the user. + display_name (str): The visible name of the user. + role (str): The role of the user within the specific context + (e.g., "owner", "member"). + email (str): The email address of the user. + """ + + id: str + user_id: str + display_name: str + role: str + email: str diff --git a/python/teams_lib_pzsp2_z1/model/mention.py b/python/teams_lib_pzsp2_z1/model/mention.py new file mode 100644 index 0000000..950f49f --- /dev/null +++ b/python/teams_lib_pzsp2_z1/model/mention.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from enum import Enum + + +class MentionKind(Enum): + """Represents the kind of mention in a Microsoft Teams message. + + Attributes: + USER: Represents a mention of a specific user. + CHANNEL: Represents a mention of the current channel. + TEAM: Represents a mention of the entire team. + EVERYONE: Represents a mention of all participants. + **Note:** This is applicable to Group Chats only. + """ + + USER = "user" + CHANNEL = "channel" + TEAM = "team" + EVERYONE = "everyone" + + +@dataclass +class Mention: + """Represents a mention entity within a Microsoft Teams message. + + This object maps a specific entity (User, Channel, etc.) to a placeholder + in the message content. Can be obtained by get_mentions() method in the Chats and Channels service. + + Attributes: + kind (MentionKind): The type of the mention. + at_id (int): The internal identifier used to link this mention to the + message content. + *Example:* If the message body contains `@John`, + this field should be `0`. + text (str): The text to display in the message (e.g., "@John Doe"). + target_id (str): The unique identifier (UUID) of the entity being mentioned + (User ID, Channel ID, or Team ID). + """ + + kind: MentionKind + at_id: int + text: str + target_id: str diff --git a/python/teams_lib_pzsp2_z1/model/message.py b/python/teams_lib_pzsp2_z1/model/message.py new file mode 100644 index 0000000..08d87c0 --- /dev/null +++ b/python/teams_lib_pzsp2_z1/model/message.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + +from teams_lib_pzsp2_z1.model.mention import Mention + + +class MessageContentType(Enum): + """Represents the type of content in a Microsoft Teams message. + + Attributes: + TEXT: Plain text content. + HTML: HTML formatted content. + """ + + TEXT = "text" + HTML = "html" + + +@dataclass +class MessageFrom: + """Represents the sender of a message in Microsoft Teams. + + Attributes: + user_id (str): The unique Azure Active Directory (AAD) identifier of the sender. + display_name (str): The visible name of the sender. + """ + + user_id: str + display_name: str + + +@dataclass +class Message: + """Represents a Microsoft Teams chat message. + + Used in both direct chats and channels. + + Attributes: + id (str): The unique identifier of the message. + content (str): The actual body text or HTML of the message. + content_type (MessageContentType): The format of the content (Text or HTML). + created_date_time (datetime): The timestamp when the message was created. + sender (MessageFrom): The user who sent the message. + **Note:** Mapped from the `From` field in the API/Go model to avoid + conflict with the Python `from` keyword. + reply_count (int): The number of replies to this message. + """ + + id: str + content: str + content_type: MessageContentType + created_date_time: datetime + sender: MessageFrom + reply_count: int + + +@dataclass +class MessageBody: + """Represents the body of a message to be sent. + + This class contains the payload structure required to send a new message + or reply. + + Attributes: + content_type (MessageContentType): The format of the content. + content (str): The text or HTML content. + mentions (List[Mention]): A list of mentions to include in the message. + """ + + content_type: MessageContentType + content: str + mentions: list[Mention] + + def __dict__(self): + """Serializes the object keys to PascalCase for the Go backend.""" + + return { + "ContentType": self.content_type.value, + "Content": self.content, + } + + def __iter__(self): + """Allows iteration over fields with PascalCase keys.""" + + yield "ContentType", self.content_type.value + yield "Content", self.content + + +@dataclass +class ListMessagesOptions: + """Contains options for listing messages (pagination and expansion). + + Attributes: + top (Optional[int]): The maximum number of messages to return in one page. + Defaults to None (server default). + expand_replies (bool): If True, the response will include replies + nested within the messages. Defaults to False. + """ + + top: int | None = None + expand_replies: bool = False + + +@dataclass +class MessageCollection: + """Represents a paginated collection of messages. + + Attributes: + messages (List[Message]): The list of message objects in the current page. + next_link (Optional[str]): The URL to retrieve the next page of results. + If None, there are no more messages. + """ + + messages: list[Message] + next_link: str | None = None diff --git a/python/teams_lib_pzsp2_z1/model/search.py b/python/teams_lib_pzsp2_z1/model/search.py new file mode 100644 index 0000000..94ee72f --- /dev/null +++ b/python/teams_lib_pzsp2_z1/model/search.py @@ -0,0 +1,169 @@ +from dataclasses import dataclass +from enum import Enum + +from teams_lib_pzsp2_z1.model.message import Message + + +@dataclass +class SearchConfig: + """ + Configuration parameters for search operations. + + Attributes: + max_workers (int): The maximum number of worker threads to use for concurrent search operations. Defaults to 8. + """ + + max_workers: int = 8 + + def __dict__(self): + """Serializes the object keys to camelCase for the Go backend.""" + return {"maxWorkers": self.max_workers} + + +@dataclass +class SearchPage: + """ + Represents pagination parameters for search results. + + Attributes: + from_ (int): The starting index of the search results. + size (int): The number of search results to return. Defaults to 25. + """ + + from_: int = 0 + size: int = 25 + + def __dict__(self): + """Serializes the object keys to camelCase for the Go backend.""" + return {"from": self.from_, "size": self.size} + + +class TimeInterval(Enum): + """Enumeration of time intervals for filtering search results. + + Attributes: + TODAY: Represents the current day. + YESTERDAY: Represents the previous day. + THIS_WEEK: Represents the current week. + THIS_MONTH: Represents the current month. + LAST_MONTH: Represents the previous month. + THIS_YEAR: Represents the current year. + LAST_YEAR: Represents the previous year. + """ + + TODAY = "today" + YESTERDAY = "yesterday" + THIS_WEEK = "this week" + THIS_MONTH = "this month" + LAST_MONTH = "last month" + THIS_YEAR = "this year" + LAST_YEAR = "last year" + + +@dataclass +class SearchMessagesOptions: + """ + Configuration options for searching messages. + + Attributes: + query (str | None): The search query string. + search_page (SearchPage | None): Pagination options. + from_ (list[str]): List of sender email addresses to include. + not_from (list[str]): List of sender email addresses to exclude. + is_read (bool | None): Filter by read status (True for read, False for unread). + is_mentioned (bool | None): Filter by mention status (True for mentioned, False for not mentioned). + to (list[str]): List of recipient email addresses to include. + not_to (list[str]): List of recipient email addresses to exclude. + start_time (datetime | None): Start time for the sent time range filter. + end_time (datetime | None): End time for the sent time range filter. + interval (TimeInterval | None): Predefined time interval for the sent time filter. + not_from_me (bool): Exclude messages sent by the current user. + not_to_me (bool): Exclude messages sent to the current user. + from_me (bool): Include only messages sent by the current user. + to_me (bool): Include only messages sent to the current user. + + Note: If interval is set, it takes precedence over start_time and end_time. + Note: Using `to` clauses works only in chats, not in team channels. + Note: Currently, queries for is_read may not function as expected due to API limitations. + """ + + query: str | None = None + search_page: SearchPage | None = None + from_: list[str] = None + not_from: list[str] = None + is_read: bool | None = None + is_mentioned: bool | None = None + to: list[str] = None + not_to: list[str] = None + start_time: object = None + end_time: object = None + interval: TimeInterval | None = None + not_from_me: bool = False + not_to_me: bool = False + from_me: bool = False + to_me: bool = False + + def __post_init__(self): + if self.from_ is None: + self.from_ = [] + if self.not_from is None: + self.not_from = [] + if self.to is None: + self.to = [] + if self.not_to is None: + self.not_to = [] + + def __dict__(self): + """Serializes the object keys to camelCase for the Go backend.""" + + result = { + "query": self.query, + "searchPage": self.search_page, + "from": self.from_, + "notFrom": self.not_from, + "isRead": self.is_read, + "isMentioned": self.is_mentioned, + "to": self.to, + "notTo": self.not_to, + "startTime": self.start_time, + "endTime": self.end_time, + "interval": self.interval.value if self.interval else None, + "notFromMe": self.not_from_me, + "notToMe": self.not_to_me, + "fromMe": self.from_me, + "toMe": self.to_me, + } + return {k: v for k, v in result.items() if v is not None and v != []} + + +@dataclass +class SearchResult: + """ + SearchResult represents a single search result containing a message and its context. + + Attributes: + message (Message): The message that matched the search criteria. + channel_id (str | None): The ID of the channel where the message was found (if applicable). + team_id (str | None): The ID of the team where the message was found (if applicable). + chat_id (str | None): The ID of the chat where the message was found (if applicable). + """ + + message: Message + channel_id: str | None = None + team_id: str | None = None + chat_id: str | None = None + + +@dataclass +class SearchResults: + """ + SearchResults represents the results of a message search. + + Attributes: + messages (list[SearchResult]): A list of search results. + next_from (int | None): The index to use for the next page of results, + or None if there are no more results. + """ + + messages: list[SearchResult] + next_from: int | None = None diff --git a/python/teams_lib_pzsp2_z1/model/team.py b/python/teams_lib_pzsp2_z1/model/team.py index 9de6e1b..aa690d7 100644 --- a/python/teams_lib_pzsp2_z1/model/team.py +++ b/python/teams_lib_pzsp2_z1/model/team.py @@ -3,8 +3,38 @@ @dataclass class Team: - ID: str - DisplayName: str - Description: str - IsArchived: bool - Visibility: str + """Represents a Microsoft Teams team. + + Attributes: + id (str): The unique identifier (UUID) of the team. + display_name (str): The name of the team. + description (str): A description of the team's purpose. + is_archived (bool): Indicates whether the team is in an archived state. + Archived teams are read-only for members. + visibility (str): The privacy setting of the team. + Typically "public", "private". + """ + + id: str + display_name: str + description: str + is_archived: bool + visibility: str + + +@dataclass +class UpdateTeam: + """Represents the subset of Team fields that can be updated. + + This class is used as a payload for patch/update operations. + Only fields that are not None will be sent to the API. + + Attributes: + display_name (Optional[str]): The new name for the team. + description (Optional[str]): The new description. + visibility (Optional[str]): The new visibility setting (e.g., "private"). + """ + + display_name: str | None = None + description: str | None = None + visibility: str | None = None diff --git a/python/teams_lib_pzsp2_z1/services/__init__.py b/python/teams_lib_pzsp2_z1/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/teams_lib_pzsp2_z1/services/base_service.py b/python/teams_lib_pzsp2_z1/services/base_service.py index 073288d..b5627b5 100644 --- a/python/teams_lib_pzsp2_z1/services/base_service.py +++ b/python/teams_lib_pzsp2_z1/services/base_service.py @@ -6,5 +6,7 @@ from teams_lib_pzsp2_z1.client import TeamsClient class BaseService: + """Base class for all service classes in the Teams library.""" + def __init__(self, client: TeamsClient): self.client = client diff --git a/python/teams_lib_pzsp2_z1/services/channels.py b/python/teams_lib_pzsp2_z1/services/channels.py index 303e74e..2c80e1d 100644 --- a/python/teams_lib_pzsp2_z1/services/channels.py +++ b/python/teams_lib_pzsp2_z1/services/channels.py @@ -1,21 +1,583 @@ +""" +This module provides the ChannelsService class for managing Microsoft Teams channels. +""" + from teams_lib_pzsp2_z1.model.channel import Channel +from teams_lib_pzsp2_z1.model.member import Member +from teams_lib_pzsp2_z1.model.mention import Mention, MentionKind +from teams_lib_pzsp2_z1.model.message import ( + Message, + MessageBody, + MessageCollection, + MessageContentType, + MessageFrom, +) +from teams_lib_pzsp2_z1.model.search import ( + SearchConfig, + SearchMessagesOptions, + SearchResult, + SearchResults, +) from teams_lib_pzsp2_z1.services.base_service import BaseService class ChannelsService(BaseService): - def list_channels(self, teamRef: str) -> list[Channel]: + """Service for managing Microsoft Teams channels via the Go backend. + + This class acts as a high-level Python wrapper. It delegates logical operations + to the underlying Go library through the `self.client.execute` bridge. + + **Architecture & Caching:** + The decision to use caching or direct API calls is handled internally by the + compiled Go library, based on the configuration provided during Client initialization. + This Python class provides a unified interface regardless of the underlying mode. + + **Concepts:** + * **References**: Arguments like `team_ref` or `channel_ref` accept either UUIDs + or Display Names. The Go backend resolves these automatically. + * **Identity**: Users are identified by UUID or Email. + """ + + def list_channels(self, team_ref: str) -> list[Channel]: + """Retrieves all channels associated with a specific team. + + Args: + team_ref (str): The reference to the team (UUID or Display Name). + Returns: + List[Channel]: A list of channel objects found in the team. + """ response = self.client.execute( cmd_type="request", method="listChannels", params={ - "teamRef": teamRef, + "teamRef": team_ref, }, ) return [ Channel( - ID=channel["ID"], - Name=channel["Name"], - IsGeneral=(True if channel["IsGeneral"] else False), + id=channel["ID"], + name=channel["Name"], + is_general=(True if channel["IsGeneral"] else False), ) for channel in response ] + + def get(self, team_ref: str, channel_ref: str) -> Channel: + """Retrieves a specific channel by its reference within a team. + + Args: + team_ref (str): The reference to the team (UUID or Display Name). + channel_ref (str): The reference to the channel (ID or Display Name). + + Returns: + Channel: The requested channel object. + + Raises: + Exception: If the reference is ambiguous or not found (propagated from Go). + """ + response = self.client.execute( + cmd_type="request", + method="getChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + }, + ) + + return Channel( + id=response["ID"], + name=response["Name"], + is_general=(True if response["IsGeneral"] else False), + ) + + def create_standard(self, team_ref: str, display_name: str) -> Channel: + """Creates a standard (public) channel within a team. + + Args: + team_ref (str): The reference to the team. + display_name (str): The display name for the new channel. + + Returns: + Channel: The newly created channel object. + """ + response = self.client.execute( + cmd_type="request", + method="createStandardChannel", + params={ + "teamRef": team_ref, + "name": display_name, + }, + ) + + return Channel( + id=response["ID"], + name=response["Name"], + is_general=(True if response["IsGeneral"] else False), + ) + + def create_private( + self, + team_ref: str, + display_name: str, + member_refs: list[str], + owner_refs: list[str], + ) -> Channel: + """Creates a private channel restricted to specific members. + + Args: + team_ref (str): The reference to the team. + display_name (str): The display name for the new private channel. + member_refs (list[str]): List of user references (IDs or emails) to add as members. + owner_refs (list[str]): List of user references to add as owners. + At least one owner is required. + + Returns: + Channel: The newly created private channel object. + """ + response = self.client.execute( + cmd_type="request", + method="createPrivateChannel", + params={ + "teamRef": team_ref, + "name": display_name, + "memberRefs": member_refs, + "ownerRefs": owner_refs, + }, + ) + + return Channel( + id=response["ID"], + name=response["Name"], + is_general=(True if response["IsGeneral"] else False), + ) + + def delete(self, team_ref: str, channel_ref: str) -> bool: + """Removes a channel from a team. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel to be deleted. + + Returns: + bool: True if the operation was successful. + """ + response = self.client.execute( + cmd_type="request", + method="deleteChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + }, + ) + return response == "deleted" + + def send_message( + self, team_ref: str, channel_ref: str, body: MessageBody + ) -> Message: + """Sends a new message to a channel. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + body (MessageBody): The message payload containing content, content type + (Text/HTML), and optional mentions. + + Returns: + Message: The created message object. + """ + response = self.client.execute( + cmd_type="request", + method="sendMessageToChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "body": dict(body), + }, + ) + + return Message( + id=response["ID"], + content=response["Content"], + content_type=MessageContentType(response["ContentType"]), + created_date_time=response["CreatedDateTime"], + sender=MessageFrom( + user_id=response["From"]["UserID"], + display_name=response["From"]["DisplayName"], + ), + reply_count=response["ReplyCount"], + ) + + def list_messages( # noqa: PLR0913 + self, + team_ref: str, + channel_ref: str, + top: int | None = 10, + expand_replies: bool = False, + include_system_messages: bool = False, + next_link: str | None = None, + ) -> MessageCollection: + """Retrieves messages from a channel. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + top (Optional[int]): The maximum number of messages to retrieve. Defaults to 10. + expand_replies (bool): If True, system fetches replies for each message. Defaults to False. + include_system_messages (bool): If True, includes system-generated messages. Defaults to False. + next_link (Optional[str]): A link for pagination to fetch the next set of messages. + + Returns: + MessageCollection: An object containing the list of messages and a next link for pagination. + """ + response = self.client.execute( + cmd_type="request", + method="listMessagesInChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "options": { + "top": top, + "expandReplies": expand_replies, + "includeSystem": include_system_messages, + "nextLink": next_link, + }, + }, + ) + + return MessageCollection( + messages=[ + Message( + id=msg["ID"], + content=msg["Content"], + content_type=MessageContentType(msg["ContentType"]), + created_date_time=msg["CreatedDateTime"], + sender=MessageFrom( + user_id=msg["From"]["UserID"], + display_name=msg["From"]["DisplayName"], + ), + reply_count=msg["ReplyCount"], + ) + for msg in response["Messages"] + ], + next_link=response.get("NextLink"), + ) + + def get_message(self, team_ref: str, channel_ref: str, message_id: str) -> Message: + """Retrieves a specific message by its ID. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + message_id (str): The unique identifier of the message. + + Returns: + Message: The requested message object. + """ + response = self.client.execute( + cmd_type="request", + method="getMessageInChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "messageID": message_id, + }, + ) + + return Message( + id=response["ID"], + content=response["Content"], + content_type=MessageContentType(response["ContentType"]), + created_date_time=response["CreatedDateTime"], + sender=MessageFrom( + user_id=response["From"]["UserID"], + display_name=response["From"]["DisplayName"], + ), + reply_count=response["ReplyCount"], + ) + + def list_message_replies( # noqa: PLR0913 + self, + team_ref: str, + channel_ref: str, + message_id: str, + top: int | None = 10, + include_system_messages: bool = False, + next_link: str | None = None, + ) -> MessageCollection: + """Retrieves all replies to a specific message thread. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + message_id (str): The ID of the parent message. + top (Optional[int]): Max number of replies to fetch. Defaults to 10. + include_system_messages (bool): If True, includes system-generated messages. Defaults to False. + next_link (Optional[str]): A link for pagination to fetch the next set of replies. + + Returns: + MessageCollection: An object containing the list of replies and a next link for pagination. + """ + response = self.client.execute( + cmd_type="request", + method="listMessageRepliesInChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "messageID": message_id, + "top": top, + "includeSystem": include_system_messages, + "nextLink": next_link, + }, + ) + + return MessageCollection( + messages=[ + Message( + id=msg["ID"], + content=msg["Content"], + content_type=MessageContentType(msg["ContentType"]), + created_date_time=msg["CreatedDateTime"], + sender=MessageFrom( + user_id=msg["From"]["UserID"], + display_name=msg["From"]["DisplayName"], + ), + reply_count=msg["ReplyCount"], + ) + for msg in response["Messages"] + ], + next_link=response.get("NextLink"), + ) + + def get_message_reply( + self, + team_ref: str, + channel_ref: str, + message_id: str, + reply_id: str, + ) -> Message: + """Retrieves a specific reply from a thread. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + message_id (str): The ID of the parent message. + reply_id (str): The ID of the reply to retrieve. + + Returns: + Message: The requested reply object. + """ + response = self.client.execute( + cmd_type="request", + method="getMessageReplyInChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "messageID": message_id, + "replyID": reply_id, + }, + ) + + return Message( + id=response["ID"], + content=response["Content"], + content_type=MessageContentType(response["ContentType"]), + created_date_time=response["CreatedDateTime"], + sender=MessageFrom( + user_id=response["From"]["UserID"], + display_name=response["From"]["DisplayName"], + ), + reply_count=response["ReplyCount"], + ) + + def list_members(self, team_ref: str, channel_ref: str) -> list[Member]: + """Lists all members of a channel. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + + Returns: + List[Member]: A list of channel members. + """ + response = self.client.execute( + cmd_type="request", + method="listChannelMembers", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + }, + ) + return [Member( + id=member["ID"], + user_id=member["UserID"], + display_name=member["DisplayName"], + role=member["Role"], + email=member["Email"], + ) for member in response] + + def add_member( + self, team_ref: str, channel_ref: str, user_ref: str, is_owner: bool + ) -> Member: + """Adds a user to a channel. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + user_ref (str): The user to add (User ID or Email). + is_owner (bool): Whether to grant Owner privileges. + + Returns: + Member: The newly added member object. + """ + response = self.client.execute( + cmd_type="request", + method="addMemberToChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "userRef": user_ref, + "isOwner": is_owner, + }, + ) + return Member( + id=response["ID"], + user_id=response["UserID"], + display_name=response["DisplayName"], + role=response["Role"], + email=response["Email"], + ) + + def update_member_role( + self, team_ref: str, channel_ref: str, user_ref: str, is_owner: bool + ) -> Member: + """Updates the role of an existing channel member. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + user_ref (str): The user reference (ID or Email). + is_owner (bool): True for Owner role, False for Member role. + + Returns: + Member: The updated member object. + """ + response = self.client.execute( + cmd_type="request", + method="updateMemberRoleInChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "userRef": user_ref, + "isOwner": is_owner, + }, + ) + return Member( + id=response["ID"], + user_id=response["UserID"], + display_name=response["DisplayName"], + role=response["Role"], + email=response["Email"], + ) + + def remove_member(self, team_ref: str, channel_ref: str, user_ref: str) -> bool: + """Removes a user from a channel. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + user_ref (str): The user reference (ID or Email) to remove. + + Returns: + bool: True if the operation was successful. + """ + response = self.client.execute( + cmd_type="request", + method="removeMemberFromChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "userRef": user_ref, + }, + ) + return response == "removed" + + def get_mentions( + self, team_ref: str, channel_ref: str, raw_mentions: list[str] + ) -> list[Mention]: + """Resolves raw mention strings into formal Mention objects. + + This is used to construct the `mentions` field for messages before sending. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + raw_mentions (List[str]): A list of raw strings to resolve. + Supported formats: + * **Email/User ID**: Resolves to a specific user. + * **'channel'**: Mentions the current channel. + * **'team'**: Mentions the parent team. + + Returns: + List[Mention]: A list of resolved Mention objects ready for the API. + """ + response = self.client.execute( + cmd_type="request", + method="getMentionsInChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "rawMentions": raw_mentions, + }, + ) + return [Mention( + kind=MentionKind(mention["Kind"]), + at_id=mention["AtID"], + text=mention["Text"], + target_id=mention["TargetID"], + ) for mention in response] + + def search_messages(self, team_ref: str, channel_ref: str, options: SearchMessagesOptions, config: SearchConfig) -> SearchResults: + """Searches for messages in a channel based on various criteria. + + Args: + team_ref (str): The reference to the team. + channel_ref (str): The reference to the channel. + options (SearchMessagesOptions): The search options and filters. + config (SearchConfig): Configuration for the search operation. + + Returns: + SearchResults: The results of the search operation. + """ + response = self.client.execute( + cmd_type="request", + method="searchMessagesInChannel", + params={ + "teamRef": team_ref, + "channelRef": channel_ref, + "searchMessagesOptions": options.__dict__(), + "searchConfig": config.__dict__(), + }, + ) + + return SearchResults( + messages=[ + SearchResult( + message=Message( + id=msg["Message"]["ID"], + content=msg["Message"]["Content"], + content_type=MessageContentType(msg["Message"]["ContentType"]), + created_date_time=msg["Message"]["CreatedDateTime"], + sender=MessageFrom( + user_id=msg["Message"]["From"]["UserID"], + display_name=msg["Message"]["From"]["DisplayName"], + ), + reply_count=msg["Message"]["ReplyCount"], + ), + channel_id=msg.get("ChannelID"), + team_id=msg.get("TeamID"), + chat_id=msg.get("ChatID"), + ) + for msg in response["Messages"] + ] if response.get("Messages") else [], + next_from=response.get("NextFrom"), + ) diff --git a/python/teams_lib_pzsp2_z1/services/chats.py b/python/teams_lib_pzsp2_z1/services/chats.py new file mode 100644 index 0000000..2524569 --- /dev/null +++ b/python/teams_lib_pzsp2_z1/services/chats.py @@ -0,0 +1,613 @@ +""" +This module contains the service for managing Microsoft Teams chats via the Go backend. +""" + +from datetime import datetime + +from teams_lib_pzsp2_z1.model.chat import Chat, ChatRef, ChatType +from teams_lib_pzsp2_z1.model.member import Member +from teams_lib_pzsp2_z1.model.mention import Mention +from teams_lib_pzsp2_z1.model.message import ( + Message, + MessageBody, + MessageCollection, + MessageContentType, + MessageFrom, +) +from teams_lib_pzsp2_z1.model.search import ( + SearchConfig, + SearchMessagesOptions, + SearchResult, + SearchResults, +) +from teams_lib_pzsp2_z1.services.base_service import BaseService + + +class ChatsService(BaseService): + """Service for managing Microsoft Teams chats via the Go backend. + + This class acts as a high-level wrapper, delegating operations to the + underlying Go library. It handles One-on-One chats, Group chats, messages, + and membership management. + + **Concepts:** + * **Chat Types**: Chats are distinct entities, either **One-on-One** (between two users) + or **Group** (multiple users, has a topic). + * **References**: + * `recipient_ref` / `user_ref`: Can be a User ID (UUID) or an Email. + * `group_chat_ref`: Can be the Chat ID or the Group Topic (if unique). + * `chat_ref` (Object): A specific model `ChatRef` containing the ID/Name and the `ChatType`. + * **Permissions**: Some operations (like `list_all_messages`) require Application Permissions + and will not work in Delegated (user context) mode. + """ + + def create_one_on_one(self, recipient_ref: str) -> Chat: + """Creates a One-on-One chat with a specific user. + + The authenticated user is automatically added to the chat. + + Args: + recipient_ref (str): The reference to the other user (User ID or Email). + + Returns: + Chat: The created chat object. + """ + response = self.client.execute( + cmd_type="request", + method="createOneOnOneChat", + params={ + "recipientRef": recipient_ref, + }, + ) + + return Chat( + id=response["ID"], + type=ChatType(response["Type"]), + is_hidden=(True if response["IsHidden"] else False), + topic=response["Topic"], + ) + + def create_group_chat( + self, recipient_refs: list[str], topic: str, include_me: bool + ) -> Chat: + """Creates a Group Chat with multiple participants. + + Args: + recipient_refs (list[str]): A list of user references (IDs or Emails) to include. + topic (str): The subject/topic of the group chat. + include_me (bool): If True, the authenticated user is added to the group. + + Returns: + Chat: The created group chat object. + """ + response = self.client.execute( + cmd_type="request", + method="createGroupChat", + params={ + "recipientRefs": recipient_refs, + "topic": topic, + "includeMe": include_me, + }, + ) + + return Chat( + id=response["ID"], + type=ChatType(response["Type"]), + is_hidden=(True if response["IsHidden"] else False), + topic=response["Topic"], + ) + + def get_chat(self, chat_ref: ChatRef) -> Chat: + """ + Retrieves a chat by its reference. + + Args: + chat_ref (ChatRef): The chat reference object containing the ID/Name and ChatType. + + Returns: + Chat: The requested chat object. + """ + response = self.client.execute( + cmd_type="request", + method="getChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + }, + ) + + return Chat( + id=response["ID"], + type=ChatType(response["Type"]), + is_hidden=(True if response["IsHidden"] else False), + topic=response["Topic"], + ) + + def add_member_to_group_chat(self, group_chat_ref: str, user_ref: str) -> Member: + """Adds a user to an existing Group Chat. + + Args: + group_chat_ref (str): The reference to the group chat (ID or Topic). + user_ref (str): The user to add (User ID or Email). + + Returns: + Member: The newly added member details. + """ + member = self.client.execute( + cmd_type="request", + method="addMemberToGroupChat", + params={ + "groupChatRef": group_chat_ref, + "userRef": user_ref, + }, + ) + + return Member( + id=member["ID"], + display_name=member["DisplayName"], + user_id=member["UserID"], + role=member["Role"], + email=member["Email"], + ) + + def remove_member_from_group_chat( + self, group_chat_ref: str, member_ref: str + ) -> bool: + """Removes a member from a Group Chat. + + Args: + group_chat_ref (str): The reference to the group chat (ID or Topic). + member_ref (str): The user reference to remove (User ID or Email). + + Returns: + bool: True if the member was successfully removed. + """ + result = self.client.execute( + cmd_type="request", + method="removeMemberFromGroupChat", + params={ + "groupChatRef": group_chat_ref, + "userRef": member_ref, + }, + ) + + return result == "removed" + + def list_group_chat_members(self, group_chat_ref: str) -> list[Member]: + """Lists all members of a Group Chat. + + Args: + group_chat_ref (str): The reference to the group chat (ID or Topic). + + Returns: + list[Member]: A list of members in the chat. + """ + members = self.client.execute( + cmd_type="request", + method="listMembersInGroupChat", + params={ + "groupChatRef": group_chat_ref, + }, + ) + + return [ + Member( + id=member["ID"], + display_name=member["DisplayName"], + user_id=member["UserID"], + role=member["Role"], + email=member["Email"], + ) + for member in members + ] + + def update_group_chat_topic(self, group_chat_ref: str, new_topic: str) -> Chat: + """Updates the topic of a Group Chat. + + Args: + group_chat_ref (str): The reference to the group chat (ID or old Topic). + new_topic (str): The new topic string. + + Returns: + Chat: The updated chat object. + """ + response = self.client.execute( + cmd_type="request", + method="updateGroupChatTopic", + params={ + "groupChatRef": group_chat_ref, + "topic": new_topic, + }, + ) + + return Chat( + id=response["ID"], + type=ChatType(response["Type"]), + is_hidden=(True if response["IsHidden"] else False), + topic=response["Topic"], + ) + + def list_messages( + self, + chat_ref: ChatRef, + include_system_messages: bool = False, + next_link: str | None = None, + ) -> list[Message]: + """Retrieves messages from a specific chat. + + Args: + chat_ref (ChatRef): The chat reference object containing the ID/Name and ChatType. + include_system_messages (bool): If True, includes system-generated messages. Defaults to False. + next_link (str | None): A link for pagination to fetch the next set of messages. Defaults to None. + + Returns: + list[Message]: A list of messages from the chat history. + """ + messages = self.client.execute( + cmd_type="request", + method="listMessagesInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "includeSystem": include_system_messages, + "nextLink": next_link, + }, + ) + + return MessageCollection( + messages=[ + Message( + id=msg["ID"], + content=msg["Content"], + content_type=MessageContentType(msg["ContentType"]), + created_date_time=msg["CreatedDateTime"], + sender=MessageFrom( + user_id=msg["From"]["UserID"], + display_name=msg["From"]["DisplayName"], + ), + reply_count=msg["ReplyCount"], + ) + for msg in messages["Messages"] + ], + next_link=messages.get("NextLink"), + ) + + def send_message(self, chat_ref: ChatRef, body: MessageBody) -> Message: + """Sends a message to a chat. + + Args: + chat_ref (ChatRef): The chat reference object. + body (MessageBody): The message payload (content, type, mentions). + + Returns: + Message: The created message object. + """ + message = self.client.execute( + cmd_type="request", + method="sendMessageInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "body": { + "content": body.content, + "contentType": body.content_type.value, + }, + }, + ) + + return Message( + id=message["ID"], + content=message["Content"], + content_type=MessageContentType(message["ContentType"]), + sender=MessageFrom( + user_id=message["From"]["UserID"], + display_name=message["From"]["DisplayName"], + ), + created_date_time=message["CreatedDateTime"], + reply_count=message["ReplyCount"], + ) + + def delete_message(self, chat_ref: ChatRef, message_id: str) -> bool: + """Soft-deletes a message from a chat. + + This action is reversible via the Graph API (though not exposed here). + + Args: + chat_ref (ChatRef): The chat reference object. + message_id (str): The unique identifier of the message to delete. + + Returns: + bool: True if the message was successfully deleted. + """ + result = self.client.execute( + cmd_type="request", + method="deleteMessageInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "messageID": message_id, + }, + ) + + return result == "deleted" + + def get_message(self, chat_ref: ChatRef, message_id: str) -> Message: + """Retrieves a single message by its ID. + + Args: + chat_ref (ChatRef): The chat reference object. + message_id (str): The unique identifier of the message. + + Returns: + Message: The requested message object. + """ + message = self.client.execute( + cmd_type="request", + method="getMessageInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "messageID": message_id, + }, + ) + + return Message( + id=message["ID"], + content=message["Content"], + content_type=MessageContentType(message["ContentType"]), + sender=MessageFrom( + user_id=message["From"]["UserID"], + display_name=message["From"]["DisplayName"], + ), + created_date_time=message["CreatedDateTime"], + reply_count=message["ReplyCount"], + ) + + def list_my_joined(self, chat_type: ChatType | None = None) -> list[Chat]: + """Lists all chats the authenticated user is part of. + + Args: + chat_type (ChatType | None, optional): Filter by chat type (e.g., only OneOnOne). + Defaults to None (return all). + + Returns: + list[Chat]: A list of chat objects. + """ + params = {} + if chat_type: + params["chatType"] = chat_type.value + + chats = self.client.execute( + cmd_type="request", + method="listMyChats", + params=params, + ) + + return [ + Chat( + id=chat["ID"], + type=ChatType(chat["Type"]), + is_hidden=(True if chat["IsHidden"] else False), + topic=chat["Topic"], + ) + for chat in chats + ] + + def list_all_messages( + self, start_time: datetime, end_time: datetime, top: int + ) -> list[Message]: + """Retrieves messages across ALL chats within a time range. + + Note: + This operation typically requires **Application Permissions** and may not + work with standard Delegated (User) credentials depending on the organization's policy. + + Args: + start_time (datetime): The start of the time range. + end_time (datetime): The end of the time range. + top (int): Maximum number of messages to retrieve. + + Returns: + list[Message]: A list of messages from all chats. + """ + messages = self.client.execute( + cmd_type="request", + method="listMyChatMessages", + params={ + "startTime": start_time.isoformat(), + "endTime": end_time.isoformat(), + "top": top, + }, + ) + + return [ + Message( + id=message["ID"], + content=message["Content"], + content_type=MessageContentType(message["ContentType"]), + sender=MessageFrom( + user_id=message["From"]["UserID"], + display_name=message["From"]["DisplayName"], + ), + created_date_time=message["CreatedDateTime"], + reply_count=message["ReplyCount"], + ) + for message in messages + ] + + def list_pinned_messages(self, chat_ref: ChatRef) -> list[Message]: + """Lists all pinned messages in a specific chat. + + Args: + chat_ref (ChatRef): The chat reference object. + + Returns: + list[Message]: A list of pinned messages. + """ + messages = self.client.execute( + cmd_type="request", + method="listPinnedMessagesInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + }, + ) + + return [ + Message( + id=message["ID"], + content=message["Content"], + content_type=MessageContentType(message["ContentType"]), + sender=MessageFrom( + user_id=message["From"]["UserID"], + display_name=message["From"]["DisplayName"], + ), + created_date_time=message["CreatedDateTime"], + reply_count=message["ReplyCount"], + ) + for message in messages + ] + + def pin_message(self, chat_ref: ChatRef, message_id: str) -> bool: + """Pins a message in the chat. + + Args: + chat_ref (ChatRef): The chat reference object. + message_id (str): The ID of the message to pin. + + Returns: + bool: True if the message was successfully pinned. + """ + result = self.client.execute( + cmd_type="request", + method="pinMessageInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "messageID": message_id, + }, + ) + + return result == "pinned" + + def unpin_message(self, chat_ref: ChatRef, message_id: str) -> bool: + """Unpins a message in the chat. + + Args: + chat_ref (ChatRef): The chat reference object. + message_id (str): The ID of the message to unpin. + + Returns: + bool: True if the message was successfully unpinned. + """ + result = self.client.execute( + cmd_type="request", + method="unpinMessageInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "messageID": message_id, + }, + ) + + return result == "unpinned" + + def get_mentions(self, chat_ref: ChatRef, raw_mentions: list[str]) -> list[Mention]: + """Resolves raw mention strings into formal Mention objects. + + This helps in processing @mentions within message content before sending. + + Args: + chat_ref (ChatRef): The chat reference object. + raw_mentions (list[str]): A list of raw strings to resolve. + Supported formats: + * **Email/User ID**: Resolves to a specific user. + * **'Everyone'**: Mentions all members (Group Chats only). + + Returns: + list[Mention]: A list of resolved Mention objects. + """ + mentions = self.client.execute( + cmd_type="request", + method="getMentionsInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "rawMentions": raw_mentions, + }, + ) + + return [ + Mention( + kind=mention["Kind"], + at_id=mention["AtID"], + text=mention["Text"], + target_id=mention["TargetID"], + ) + for mention in mentions + ] + + def search_messages(self, chat_ref: ChatRef, options: SearchMessagesOptions, config: SearchConfig) -> SearchResults: + """Searches for messages in a chat based on various criteria. + + Args: + chat_ref (ChatRef): The chat reference object. + options (SearchMessagesOptions): The search options and filters. + config (SearchConfig): Configuration for the search operation. + + Returns: + SearchResults: The results of the search operation. + """ + response = self.client.execute( + cmd_type="request", + method="searchMessagesInChat", + params={ + "chatRef": { + "ref": chat_ref.ref, + "type": chat_ref.type.value, + }, + "searchMessagesOptions": options.__dict__(), + "searchConfig": config.__dict__(), + }, + ) + + return SearchResults( + messages=[ + SearchResult( + message=Message( + id=msg["Message"]["ID"], + content=msg["Message"]["Content"], + content_type=MessageContentType(msg["Message"]["ContentType"]), + created_date_time=msg["Message"]["CreatedDateTime"], + sender=MessageFrom( + user_id=msg["Message"]["From"]["UserID"], + display_name=msg["Message"]["From"]["DisplayName"], + ), + reply_count=msg["Message"]["ReplyCount"], + ), + channel_id=msg.get("ChannelID"), + team_id=msg.get("TeamID"), + chat_id=msg.get("ChatID"), + ) + for msg in response["Messages"] + ] if response.get("Messages") else [], + next_from=response.get("NextFrom"), + ) diff --git a/python/teams_lib_pzsp2_z1/services/teams.py b/python/teams_lib_pzsp2_z1/services/teams.py new file mode 100644 index 0000000..06646c6 --- /dev/null +++ b/python/teams_lib_pzsp2_z1/services/teams.py @@ -0,0 +1,239 @@ +""" +This module contains the service for managing Microsoft Teams via the Go backend. +""" + +from teams_lib_pzsp2_z1.model.team import Team, UpdateTeam +from teams_lib_pzsp2_z1.services.base_service import BaseService + + +class TeamsService(BaseService): + """Service for managing Microsoft Teams lifecycle and properties via the Go backend. + + This class acts as a high-level Python wrapper. It delegates logical operations + to the underlying Go library. + + **Concepts:** + * **References**: The `teamRef` argument accepts either the Team UUID or the + Team Display Name. The Go backend resolves these automatically. + * **Context**: Operations are executed on behalf of the authenticated user + (derived from the MSAL context). + * **Caching**: If the Go client was initialized with cache enabled, this service + will transparently use cached team references to reduce API calls. + """ + + def get(self, team_ref: str) -> Team: + """Retrieves a specific team by its reference. + + Args: + team_ref (str): The reference to the team (UUID or Display Name). + Returns: + Team: The requested team object containing details like ID, DisplayName, and Visibility. + """ + response = self.client.execute( + cmd_type="request", + method="getTeam", + params={ + "teamRef": team_ref, + }, + ) + + return Team( + id=response["ID"], + display_name=response["DisplayName"], + description=response["Description"], + is_archived=(True if response["IsArchived"] else False), + visibility=response["Visibility"], + ) + + def list_my_joined(self) -> list[Team]: + """Lists all teams that the authenticated user has joined. + + Returns: + list[Team]: A list of team objects the user is a member of. + """ + response = self.client.execute( + cmd_type="request", + method="listMyJoined", + params={}, + ) + return [ + Team( + id=team["ID"], + display_name=team["DisplayName"], + description=team["Description"], + is_archived=(True if team["IsArchived"] else False), + visibility=team["Visibility"], + ) + for team in response + ] + + def update(self, team_ref: str, update: UpdateTeam) -> Team: + """Applies updates to a team's properties. + + Only fields that are not None in the `update` object will be modified. + + Args: + team_ref (str): The reference to the team (UUID or Display Name). + update (UpdateTeam): An object containing the fields to update (e.g., DisplayName). + + Returns: + Team: The updated team object. + """ + params = {"teamRef": team_ref} + # Filter out None values to perform a PATCH-like update + update_dict = {k: v for k, v in update.__dict__.items() if v is not None} + params["team"] = update_dict + + response = self.client.execute( + cmd_type="request", + method="updateTeam", + params=params, + ) + return Team( + id=response["ID"], + display_name=response["DisplayName"], + description=response["Description"], + is_archived=(True if response["IsArchived"] else False), + visibility=response["Visibility"], + ) + + def create_via_group( + self, display_name: str, mail_nickname: str, visibility: str + ) -> Team: + """Creates a new team associated with a standard Microsoft 365 group. + + Args: + display_name (str): The name of the new team. + mail_nickname (str): The mail alias for the underlying group. + visibility (str): The privacy level ('Public' or 'Private'). + + Returns: + Team: The newly created team object. + """ + response = self.client.execute( + cmd_type="request", + method="createTeamViaGroup", + params={ + "displayName": display_name, + "mailNickname": mail_nickname, + "visibility": visibility, + }, + ) + return Team( + id=response["ID"], + display_name=response["DisplayName"], + description=response["Description"], + is_archived=(True if response["IsArchived"] else False), + visibility=response["Visibility"], + ) + + def create_from_template( # noqa: PLR0913 + self, + display_name: str, + description: str, + owners: list[str], + members: list[str] | None = None, + visibility: str = "public", + include_me: bool = True, + ) -> str: + """Creates a new team based on a template. + + This operation is often asynchronous. + + Args: + display_name (str): The name of the team. + description (str): A brief description of the team. + owners (list[str]): A list of user references (IDs or emails) to be owners. + members (list[str] | None, optional): A list of user references to be members. Defaults to None. + visibility (str, optional): The privacy level ('public' or 'private'). Defaults to "public". + include_me (bool, optional): Whether to add the authenticated user as an owner. Defaults to True. + + Returns: + str: The ID of the newly created team (or the async operation ID). + """ + response = self.client.execute( + cmd_type="request", + method="createTeamFromTemplate", + params={ + "displayName": display_name, + "description": description, + "owners": owners, + "members": members, + "visibility": visibility, + "includeMe": include_me, + }, + ) + return response + + def archive(self, team_ref: str, spo_read_only_from_members: bool) -> bool: + """Archives a team. + + Args: + team_ref (str): The reference to the team (UUID or Display Name). + spo_read_only_from_members (bool): If True, sets the associated SharePoint site + to read-only for members. + + Returns: + bool: True if the team was successfully archived. + """ + response = self.client.execute( + cmd_type="request", + method="archiveTeam", + params={ + "teamRef": team_ref, + "spoReadOnlyFromMembers": spo_read_only_from_members, + }, + ) + return response == "archived" + + def unarchive(self, team_ref: str) -> bool: + """Restores an archived team to an active state. + + Args: + team_ref (str): The reference to the team (UUID or Display Name). + Returns: + bool: True if the team was successfully unarchived. + """ + response = self.client.execute( + cmd_type="request", + method="unarchiveTeam", + params={ + "teamRef": team_ref, + }, + ) + return response == "unarchived" + + def delete(self, team_ref: str) -> bool: + """Deletes a team (soft delete). + + Args: + team_ref (str): The reference to the team (UUID or Display Name). + Returns: + bool: True if the delete request was successful. + """ + response = self.client.execute( + cmd_type="request", + method="deleteTeam", + params={ + "teamRef": team_ref, + }, + ) + return response == "deleted" + + def restore_deleted(self, deleted_team_ID: str) -> str: + """Restores a previously deleted team. + + Args: + deleted_team_ID (str): The ID of the deleted group/team. + + Returns: + str: The ID of the restored team/group. + """ + response = self.client.execute( + cmd_type="request", + method="restoreDeletedTeam", + params={ + "deletedGroupID": deleted_team_ID, + }, + ) + return response diff --git a/python/tests/fake_server/data.py b/python/tests/fake_server/data.py index d917f45..f3cb45d 100644 --- a/python/tests/fake_server/data.py +++ b/python/tests/fake_server/data.py @@ -1,61 +1,879 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +import re +import traceback +from typing import Optional, List, Dict, Any +from unittest import result +from urllib import request + +from werkzeug import Response + from teams_lib_pzsp2_z1.model.team import Team +from teams_lib_pzsp2_z1.model.chat import Chat, ChatType from teams_lib_pzsp2_z1.model.channel import Channel +from teams_lib_pzsp2_z1.model.message import Message, MessageFrom +from teams_lib_pzsp2_z1.model.member import Member + +# --- Constants for OData --- +ODATA_CONTEXT = "@odata.context" +ODATA_TYPE = "@odata.type" +ODATA_COUNT = "@odata.count" +GRAPH_URL = "https://graph.microsoft.com/v1.0/$metadata" @dataclass class FakeServerData: - teams: list[Team] - channels: dict[str, list[Channel]] + teams: List[Team] + channels: Dict[str, List[Channel]] + + # State tracking variables + newGroupID: str + newTeamName: str + newGroupMailNickname: str + newTeamVisibility: str + potentialTeams: List[Team] = field(default_factory=list) + searching: str = "chats" - def __init__(self) -> FakeServerData: + # Initial data containers + messages: Dict[str, List[Message]] = field(default_factory=dict) + replies: Dict[str, List[Message]] = field(default_factory=dict) + members: Dict[str, Dict[str, List[Member]]] = field(default_factory=dict) + group_chat_members: Dict[str, List[Member]] = field(default_factory=dict) + chat_messages: Dict[str, List[Message]] = field(default_factory=dict) + users: List[Member] = field(default_factory=list) + me: Member = field(default_factory=Member) # type: ignore + group_chats: List[Chat] = field(default_factory=list) + oneonone_chats: List[Chat] = field(default_factory=list) + + # Templates and temporary state + newChannelName: str = "New Channel" + newChannelID: str = "19:newchannelid@thread.tacv2" + newMessageTemplate: Message = field(default_factory=lambda: Message( + id="new-message-id", + content="This is a new message.", + content_type="text", + sender=MessageFrom(user_id="user-new-001", display_name="New User"), + created_date_time="2024-01-02T10:00:00Z", + reply_count=0, + )) + newMemberTemplate: Member = field(default_factory=lambda: Member( + id="user-new-002", user_id="new-user-002", display_name="New Member", + role="owner", email="newmember@example.com" + )) + newChatTemplate: Chat = field(default_factory=lambda: Chat( + id="new-chat-001", type=ChatType.GROUP, is_hidden=False, topic="New Chat Topic" + )) + updatedGroupChatTopic: str = "Updated Chat Topic" + + def __init__(self) -> None: + # --- Teams --- self.teams = [ - Team( - ID="team-123-abc", - DisplayName="Test Team", - Description="A team for testing", - IsArchived=False, - Visibility="Private", - ), + Team(id="team-123-abc", display_name="Test Team", description="A team for testing", is_archived=False, visibility="private"), + Team(id="team-456-def", display_name="Another Team", description="Another team for testing", is_archived=False, visibility="public"), + Team(id="archived-team-789-ghi", display_name="Archived Team", description="An archived team for testing", is_archived=True, visibility="private") ] + self.potentialTeams = [] + + # --- Channels --- self.channels = { "team-123-abc": [ - Channel( - ID="19:123123@thread.tacv2", - Name="General", - IsGeneral=True, - ), - Channel( - ID="19:999999@thread.tacv2", - Name="Development", - IsGeneral=False, - ), + Channel(id="19:123123@thread.tacv2", name="Something", is_general=False), + Channel(id="19:999999@thread.tacv2", name="Development", is_general=False), + ], + } + + # --- Messages (Channel) --- + self.messages = { + "19:123123@thread.tacv2": [ + Message(id="msg-001", content="Hello, team!", content_type="text", sender=MessageFrom(user_id="user-123-abc", display_name="Alice"), created_date_time="2024-01-01T10:00:00Z", reply_count=0), + Message(id="msg-002", content="Don't forget the meeting at 3 PM.", content_type="text", sender=MessageFrom(user_id="user-456-def", display_name="Bob"), created_date_time="2024-01-01T11:00:00Z", reply_count=2), + Message(id="AAMkAGIwMDA5MmY0LWY5ZTgtNGY5YS04NzczLWNhNjc0ZGIyZDBjYgBGAAAAAADm35sgHbzESapJ8_BjBlhEBwDAYtphe7dsRbDrOT-HAHoKAACmqNsoAADAYtphe7dsRbDrOT-HAHoKAAFsBhyEAAA=", content="Hello, team!", content_type="text", sender=MessageFrom(user_id="user-123-abc", display_name="Alice"), created_date_time="2024-01-01T10:00:00Z", reply_count=0), + ], + } + + # --- Replies --- + self.replies = { + "msg-002": [ + Message(id="msg-002-reply-001", content="Thanks for the reminder!", content_type="text", sender=MessageFrom(user_id="user-789-ghi", display_name="Charlie"), created_date_time="2024-01-01T12:00:00Z", reply_count=0), + Message(id="msg-002-reply-002", content="I'll be there.", content_type="text", sender=MessageFrom(user_id="user-123-abc", display_name="Alice"), created_date_time="2024-01-01T12:30:00Z", reply_count=0), ], } + # --- Members (Channel) --- + self.members = { + "team-123-abc": { + "19:123123@thread.tacv2": [ + Member(id="user-123-abc", user_id="user-123-abc", display_name="Alice", role="owner", email="alice@example.com"), + Member(id="user-456-def", user_id="user-456-def", display_name="Bob", role="member", email="bob@example.com"), + ], + } + } + + # --- Users --- + self.users = [ + Member(id="user-123-abc", user_id="user-123-abc", display_name="Alice", role="owner", email="alice@example.com"), + Member(id="user-456-def", user_id="user-456-def", display_name="Bob", role="member", email="bob@example.com"), + ] + self.me = Member(id="user-me-001", user_id="user-me-001", display_name="Current User", role="member", email="me@example.com") + + # --- Chats & Chat Members/Messages --- + self.group_chats = [ + Chat(id="chat-123-abc", type=ChatType.GROUP, is_hidden=False, topic="Project Discussion"), + Chat(id="chat-456-def", type=ChatType.GROUP, is_hidden=True, topic="Secret Plans"), + ] + self.oneonone_chats = [ + Chat(id="chat-789-ghi", type=ChatType.ONE_ON_ONE, is_hidden=False, topic=""), + ] + + self.group_chat_members = { + "chat-123-abc": [ + Member(id="user-123-abc", user_id="user-123-abc", display_name="Alice", role="owner", email="alice@example.com"), + Member(id="user-456-def", user_id="user-456-def", display_name="Bob", role="member", email="bob@example.com"), + ], + } + + self.chat_messages = { + "chat-123-abc": [ + Message(id="AAMkAGIwMDA5MmY0LWY5ZTgtNGY5YS04NzczLWNhNjc0ZGIyZDBjYgBGAAAAAADm35sgHbzESapJ8_BjBlhEBwDAYtphe7dsRbDrOT-HAHoKAACmqNsoAADAYtphe7dsRbDrOT-HAHoKAAFsBhyEAAA=", content="Hello, team!", content_type="text", sender=MessageFrom(user_id="user-123-abc", display_name="Alice"), created_date_time="2024-01-01T10:00:00Z", reply_count=0), + Message(id="BAMkAGIwMDA5MmY0LWY5ZTgtNGY5YS04NzczLWNhNjc0ZGIyZDBjYgBGAAAAAADm35sgHbzESapJ8_BjBlhEBwDAYtphe7dsRbDrOT-HAHoKAACmqNsoAADAYtphe7dsRbDrOT-HAHoKAAFsBhyEAAA=", content="Don't forget the meeting at 3 PM.", content_type="text", sender=MessageFrom(user_id="user-456-def", display_name="Bob"), created_date_time="2024-01-01T11:00:00Z", reply_count=2), + ], + } + + # --- State Variables --- + self.newGroupID = "group-789-ghi" + self.newTeamName = "New Team" + self.newGroupMailNickname = "new-team-nickname" + self.newTeamVisibility = "private" + self.newTeamID = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + + # Init Templates (re-assigning to ensure freshness if needed, though default_factory handles it) + self.newChannelName = "New Channel" + self.newChannelID = "19:newchannelid@thread.tacv2" + self.updatedGroupChatTopic = "Updated Chat Topic" + + # Initialize templates manually to match original structure exactly if factories behave differently + self.newMessageTemplate = Message( + id="new-message-id", content="This is a new message.", content_type="text", + sender=MessageFrom(user_id="user-new-001", display_name="New User"), + created_date_time="2024-01-02T10:00:00Z", reply_count=0 + ) + self.newMemberTemplate = Member( + id="user-new-002", user_id="new-user-002", display_name="New Member", + role="owner", email="newmember@example.com" + ) + self.newChatTemplate = Chat( + id="new-chat-001", type=ChatType.GROUP, is_hidden=False, topic="New Chat Topic" + ) + + # --- Helpers --- + def _find_team(self, team_id: str) -> Optional[Team]: + return next((t for t in self.teams if t.id == team_id), None) + + def _find_channel(self, team_id: str, channel_id: str) -> Optional[Channel]: + return next((c for c in self.channels.get(team_id, []) if c.id == channel_id), None) + + # ========================================== + # TEAMS & GROUPS + # ========================================== + def get_myJoinedTeams_response(self) -> dict: return { - "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#teams", "value": [ { - "id": team.ID, - "displayName": team.DisplayName, - "description": team.Description, - "isArchived": team.IsArchived, - "visibility": team.Visibility, + "id": team.id, + "displayName": team.display_name, + "description": team.description, + "isArchived": team.is_archived, + "visibility": team.visibility, } for team in self.teams ], } + def get_team_response(self, team_id: str) -> Optional[dict]: + team = self._find_team(team_id) + if not team: + return None + return { + "id": team.id, + "displayName": team.display_name, + "description": team.description, + "isArchived": team.is_archived, + "visibility": team.visibility, + } + + def get_updateTeam_response(self, team_id: str, update_json: dict) -> Optional[dict]: + team = self._find_team(team_id) + if not team: + return None + + if "displayName" in update_json: + team.display_name = update_json["displayName"] + if "description" in update_json: + team.description = update_json["description"] + if "visibility" in update_json: + team.visibility = update_json["visibility"] + + return { + "id": team.id, + "displayName": team.display_name, + "description": team.description, + "isArchived": team.is_archived, + "visibility": team.visibility, + } + + def get_createGroup_response(self, request_json: dict) -> dict: + visibility = request_json.get("visibility", "").lower() + if visibility not in ("private", "public"): + visibility = "private" + + self.potentialTeams.append( + Team( + id=self.newGroupID, + display_name=request_json.get("displayName"), + description=request_json.get("description"), + is_archived=False, + visibility=visibility, + ) + ) + + return { + "id": self.newGroupID, + "displayName": request_json.get("displayName"), + "description": request_json.get("displayName"), + "visibility": visibility, + "groupTypes": ["Unified"], + "mailEnabled": True, + "mailNickname": request_json.get("mailNickname"), + "securityEnabled": False, + "createdDateTime": "2024-01-01T00:00:00Z", + } + + def get_createTeamViaGroup_response(self, group_id: str) -> dict: + team = next((t for t in self.potentialTeams if t.id == group_id), None) + if not team: + return {} + + self.teams.append(team) + + return { + "id": team.id, + "displayName": team.display_name, + "description": team.description, + "isArchived": team.is_archived, + "visibility": team.visibility, + } + + def get_createTeamFromTemplate_response(self, request: request.Request) -> Response: + request_json = request.json + new_team = Team( + id=self.newTeamID, + display_name=request_json.get("displayName"), + description=request_json.get("description"), + is_archived=False, + visibility="private", + ) + self.teams.append(new_team) + + # self.newTeamID == "f47ac10b-58cc-4372-a567-0e02b2c3d479" + op_id = "00000000-0000-0000-0000-000000000000" + + return Response( + status= 202, + headers= { + "Location": f"/teams('{self.newTeamID}')/operations('{op_id}')", + "Content-Location": f"/teams('{self.newTeamID}')", + "Content-Type": "application/json", + "Content-Length": "0" + }, + response= "" + ) + + def get_archiveTeam_response(self, team_id: str) -> dict: + team = self._find_team(team_id) + if not team: + return {"success": False} + team.is_archived = True + return {"success": True} + + def get_unarchiveTeam_response(self, team_id: str) -> dict: + team = self._find_team(team_id) + if not team: + return {"success": False} + team.is_archived = False + return {"success": True} + + def get_deleteTeam_response(self, team_id: str) -> dict: + team = self._find_team(team_id) + if not team: + return {"success": False} + self.teams.remove(team) + return {"success": True} + + def get_restoreTeam_response(self, team_id: str) -> dict: + archived_team = next((t for t in self.teams if t.id == team_id and t.is_archived), None) + if not archived_team: + return {"success": False} + + archived_team.is_archived = False + return { + "id": archived_team.id, + "displayName": archived_team.display_name, + "description": archived_team.description, + "isArchived": archived_team.is_archived, + "visibility": archived_team.visibility, + "mailNickname": self.newGroupMailNickname + } + + # ========================================== + # CHANNELS + # ========================================== + def get_listChannels_response(self, team_id: str) -> dict: return { - "@odata.context": f"https://graph.microsoft.com/v1.0/$metadata#teams('{team_id}')/channels", + ODATA_CONTEXT: f"{GRAPH_URL}#teams('{team_id}')/channels", "value": [ { - "id": channel.ID, - "displayName": channel.Name, - "isGeneral": channel.IsGeneral, + ODATA_TYPE: "#microsoft.graph.channel", + "id": channel.id, + "displayName": channel.name, + "isGeneral": channel.is_general, + "membershipType": "standard", + "email": "" } for channel in self.channels.get(team_id, []) ], - } \ No newline at end of file + } + + def get_channel_response(self, team_id: str, channel_id: str) -> Optional[dict]: + channel = self._find_channel(team_id, channel_id) + if not channel: + return None + return { + "id": channel.id, + "displayName": channel.name, + "isGeneral": channel.is_general, + } + + def get_create_channel_response(self, team_id: str, request_json: dict) -> dict: + display_name = request_json.get("displayName") + description = request_json.get("description", display_name) + + new_channel = Channel( + id=self.newChannelID, + name=display_name, + is_general=False, + ) + + if team_id not in self.channels: + self.channels[team_id] = [] + self.channels[team_id].append(new_channel) + + return { + "id": new_channel.id, + "displayName": new_channel.name, + "description": description, + "isGeneral": new_channel.is_general, + "membershipType": "standard" + } + + def get_delete_channel_response(self, team_id: str, channel_id: str) -> dict: + channel = self._find_channel(team_id, channel_id) + if not channel: + return {"success": False} + self.channels[team_id].remove(channel) + return {"success": True} + + # ========================================== + # MESSAGES (CHANNEL) + # ========================================== + + def get_list_messages_response(self, team_id: str, channel_id: str) -> dict: + return { + "value": [ + { + "id": message.id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": message.sender.user_id, + "displayName": message.sender.display_name, + } + }, + "createdDateTime": message.created_date_time, + "replies": [{"id": f"dummy-reply-{i}"} for i in range(message.reply_count)] + } + for message in self.messages.get(channel_id, []) + ], + } + + def get_message_response(self, team_id: str, channel_id: str, message_id: str) -> Optional[dict]: + message = next((m for m in self.messages.get(channel_id, []) if m.id == message_id), None) + if not message: + return None + + return { + "id": message.id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": message.sender.user_id, + "displayName": message.sender.display_name, + } + }, + "createdDateTime": message.created_date_time, + "replies": [{"id": f"dummy-reply-{i}"} for i in range(message.reply_count)] + } + + def get_send_message_response(self, team_id: str, channel_id: str, request_json: dict) -> dict: + return { + "id": self.newMessageTemplate.id, + "body": { + "content": request_json.get("body", {}).get("content"), + "contentType": request_json.get("body", {}).get("contentType"), + }, + "from": { + "user": { + "id": self.newMessageTemplate.sender.user_id, + "displayName": self.newMessageTemplate.sender.display_name, + } + }, + "createdDateTime": self.newMessageTemplate.created_date_time, + } + + def get_list_replies_response(self, team_id: str, channel_id: str, message_id: str) -> dict: + return { + "value": [ + { + "id": reply.id, + "body": { + "content": reply.content, + "contentType": reply.content_type, + }, + "from": { + "user": { + "id": reply.sender.user_id, + "displayName": reply.sender.display_name, + } + }, + "createdDateTime": reply.created_date_time, + } + for reply in self.replies.get(message_id, []) + ], + } + + def get_reply_response(self, team_id: str, channel_id: str, message_id: str, reply_id: str) -> Optional[dict]: + reply = next((r for r in self.replies.get(message_id, []) if r.id == reply_id), None) + if not reply: + return None + return { + "id": reply.id, + "body": { + "content": reply.content, + "contentType": reply.content_type, + }, + "from": { + "user": { + "id": reply.sender.user_id, + "displayName": reply.sender.display_name, + } + }, + "createdDateTime": reply.created_date_time, + } + + # ========================================== + # MEMBERS (CHANNEL) + # ========================================== + + def get_list_members_response(self, team_id: str, channel_id: str) -> dict: + return { + ODATA_CONTEXT: f"{GRAPH_URL}#teams('{team_id}')/channels('{channel_id}')/members", + "value": [ + { + ODATA_TYPE: "#microsoft.graph.aadUserConversationMember", + "id": member.id, + "userId": member.user_id, + "displayName": member.display_name, + "roles": [member.role] if member.role == "owner" else [], + "email": member.email, + } + for member in self.members.get(team_id, {}).get(channel_id, []) + ], + } + + def get_add_member_response(self, team_id: str, request_json: dict) -> dict: + roles = ["owner"] if self.newMemberTemplate.role == "owner" else [] + return { + ODATA_CONTEXT: f"{GRAPH_URL}#teams('{team_id}')/members/$entity", + ODATA_TYPE: "#microsoft.graph.aadUserConversationMember", + "id": self.newMemberTemplate.id, + "roles": roles, + "displayName": self.newMemberTemplate.display_name, + "userId": self.newMemberTemplate.user_id, + "email": self.newMemberTemplate.email, + } + + def get_update_member_role_response(self, team_id: str, channel_id: str, member_id: str, request_json: dict) -> Optional[dict]: + channel_members = self.members.get(team_id, {}).get(channel_id, []) + member = next((m for m in channel_members if m.id == member_id), None) + if not member: + return None + + if "roles" in request_json: + member.role = "owner" if "owner" in request_json["roles"] else "member" + + return { + ODATA_CONTEXT: f"{GRAPH_URL}#teams('{team_id}')/channels('{channel_id}')/members/$entity", + ODATA_TYPE: "#microsoft.graph.aadUserConversationMember", + "id": member.id, + "userId": member.user_id, + "displayName": member.display_name, + "roles": [member.role] if member.role == "owner" else [], + "email": member.email, + } + + def get_remove_member_response(self, team_id: str, channel_id: str, member_id: str) -> dict: + channel_members = self.members.get(team_id, {}).get(channel_id, []) + member = next((m for m in channel_members if m.id == member_id), None) + if not member: + return {"success": False} + channel_members.remove(member) + return {"success": True} + + # ========================================== + # CHATS + # ========================================== + + def get_chat_responses(self, chat_id: str) -> dict: + chat = next((c for c in self.group_chats + self.oneonone_chats if c.id == chat_id), None) + if not chat: + return {} + + return { + ODATA_CONTEXT: "https://graph.microsoft.com/v1.0/$metadata#chats/$entity", + "id": chat.id, + "chatType": "oneOnOne" if chat.type == ChatType.ONE_ON_ONE else "group", + "isHiddenForAllMembers": chat.is_hidden, + "topic": chat.topic, + } + + def get_list_chats_response(self, chat_type: ChatType) -> dict: + chats = self.oneonone_chats if chat_type == ChatType.ONE_ON_ONE else self.group_chats + return { + ODATA_CONTEXT: f"{GRAPH_URL}#chats", + ODATA_COUNT: len(chats), + "value": [ + { + "id": chat.id, + "chatType": "oneOnOne" if chat.type == ChatType.ONE_ON_ONE else "group", + "isHiddenForAllMembers": chat.is_hidden, + "topic": chat.topic, + } + for chat in chats + ], + } + + def get_create_chat_response(self, request_json: dict) -> dict: + is_one_on_one = request_json["chatType"] == "oneOnOne" + response = { + ODATA_CONTEXT: f"{GRAPH_URL}#chats/$entity", + "id": self.newChatTemplate.id, + "chatType": "oneOnOne" if is_one_on_one else "group", + "isHiddenForAllMembers": self.newChatTemplate.is_hidden, + "topic": None if is_one_on_one else self.newChatTemplate.topic, + } + return response + + def get_update_group_chat_topic_response(self, chat_id: str, request_json: dict) -> dict: + chat = next((c for c in self.group_chats if c.id == chat_id), None) + if not chat: + return {} + + chat.topic = request_json.get("topic", chat.topic) + return { + ODATA_CONTEXT: f"{GRAPH_URL}#chats('{chat_id}')", + "id": chat.id, + "chatType": "group", + "isHiddenForAllMembers": chat.is_hidden, + "topic": chat.topic, + } + + # ========================================== + # CHAT MEMBERS + # ========================================== + + def get_list_group_chat_members_response(self, chat_id: str) -> dict: + return { + ODATA_CONTEXT: f"{GRAPH_URL}#chats('{chat_id}')/members", + "value": [ + { + ODATA_TYPE: "#microsoft.graph.aadUserConversationMember", + "id": member.id, + "userId": member.user_id, + "displayName": member.display_name, + "roles": [member.role] if member.role == "owner" else [], + "email": member.email, + } + for member in self.group_chat_members.get(chat_id, []) + ], + } + + def get_add_member_to_group_chat_response(self, chat_id: str, request_json: dict) -> dict: + return { + ODATA_CONTEXT: f"{GRAPH_URL}#chats('{chat_id}')/members/$entity", + ODATA_TYPE: "#microsoft.graph.aadUserConversationMember", + "id": self.newMemberTemplate.id, + "roles": [self.newMemberTemplate.role] if self.newMemberTemplate.role == "owner" else [], + "displayName": self.newMemberTemplate.display_name, + "userId": self.newMemberTemplate.user_id, + "email": self.newMemberTemplate.email, + } + + def get_remove_member_from_group_chat_response(self, chat_id: str, member_id: str) -> dict: + return {"success": True} + + # ========================================== + # CHAT MESSAGES + # ========================================== + + + def get_list_messages_in_chat_response(self, chat_id: str) -> dict: + return { + "value": [ + { + "id": message.id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": message.sender.user_id, + "displayName": message.sender.display_name, + } + }, + "createdDateTime": message.created_date_time, + "replies": [{"id": f"dummy-reply-{i}"} for i in range(message.reply_count)] + } + for message in self.chat_messages.get(chat_id, []) + ], + } + + def get_get_message_in_chat_response(self, chat_id: str, message_id: str) -> Optional[dict]: + message = next((m for m in self.chat_messages.get(chat_id, []) if m.id == message_id), None) + if not message: + return None + + return { + "id": message.id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": message.sender.user_id, + "displayName": message.sender.display_name, + } + }, + "createdDateTime": message.created_date_time, + "replies": [{"id": f"dummy-reply-{i}"} for i in range(message.reply_count)] + } + + def get_send_message_in_chat_response(self, chat_id: str, request_json: dict) -> dict: + return { + "id": self.newMessageTemplate.id, + "body": { + "content": request_json.get("body", {}).get("content"), + "contentType": request_json.get("body", {}).get("contentType"), + }, + "from": { + "user": { + "id": self.newMessageTemplate.sender.user_id, + "displayName": self.newMessageTemplate.sender.display_name, + } + }, + "createdDateTime": self.newMessageTemplate.created_date_time, + } + + def get_all_messeges_in_chats_response(self) -> dict: + target_chat_id = self.group_chats[0].id + return { + ODATA_CONTEXT: f"{GRAPH_URL}#Collection(chatMessage)", + ODATA_COUNT: len(self.chat_messages), + "value": [ + { + ODATA_TYPE: "#microsoft.graph.chatMessage", + "id": message.id, + "etag": message.id, + "messageType": "message", + "chatId": target_chat_id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": message.sender.user_id, + "displayName": message.sender.display_name, + } + }, + "createdDateTime": message.created_date_time, + "lastModifiedDateTime": message.created_date_time, + } + for message in self.chat_messages[target_chat_id] + ], + } + + def get_list_pinned_messages_in_chat_response(self, chat_id: str) -> dict: + return { + "value": [ + { + "id": message.id, + "message": { + "id": message.id, + "etag": message.id, + "messageType": "message", + "chatId": chat_id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": message.sender.user_id, + "displayName": message.sender.display_name, + } + }, + "createdDateTime": message.created_date_time, + "lastModifiedDateTime": message.created_date_time, + } + } + for message in self.chat_messages.get(chat_id, []) + ], + } + + # ========================================== + # USERS + # ========================================== + + def get_me_response(self) -> dict: + return { + "id": self.me.id, + "displayName": self.me.display_name, + "email": self.me.email, + } + + # ========================================== + # SEARCH + # ========================================== + + def get_search_messages_response(self, request_body) -> dict: + hits = [] + rank = 1 + messages = [] + + raw_query = "" + try: + if isinstance(request_body, dict): + req_list = request_body.get('requests', []) + if req_list: + raw_query = req_list[0].get('query', {}).get('queryString', "") + except Exception: + raw_query = "" + + search_term = re.sub(r'\w+:"[^"]+"', '', raw_query) + + search_term = search_term.strip() + + + try: + for chat_id, messages in self.chat_messages.items(): + for message in messages: + if not message.content: + continue + + if search_term and search_term.lower() in message.content.lower(): + + sender_id = getattr(message.sender, 'user_id', 'unknown') if message.sender else 'unknown' + sender_name = getattr(message.sender, 'display_name', 'Unknown') if message.sender else 'Unknown' + created_dt = getattr(message, 'created_date_time', "2024-01-01T12:00:00Z") + + if self.searching == "chats": + hits.append({ + "hitId": message.id, + "rank": rank, + "summary": message.content[:200], + "resource": { + "id": message.id, + "chatId": chat_id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": sender_id, + "displayName": sender_name, + } + }, + "createdDateTime": created_dt, + "lastModifiedDateTime": created_dt, + } + }, + ) + elif self.searching == "channels": + hits.append({ + "hitId": message.id, + "rank": rank, + "summary": message.content[:200], + "resource": { + "id": message.id, + "channelIdentity": { + "teamId": self.teams[0].id, + "channelId": self.channels[self.teams[0].id][0].id, + }, + "teamId": self.teams[0].id, + "channelId": self.channels[self.teams[0].id][0].id, + "body": { + "content": message.content, + "contentType": message.content_type, + }, + "from": { + "user": { + "id": sender_id, + "displayName": sender_name, + } + }, + "createdDateTime": created_dt, + "lastModifiedDateTime": created_dt, + } + }, + ) + rank += 1 + + except Exception: + traceback.print_exc() + + response = { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(microsoft.graph.searchResponse)", + "value": [ + { + "searchTerms": [search_term], + "hitsContainers": [ + { + "hits": hits, + "total": len(hits), + "moreResultsAvailable": False, + } + ], + } + ] + } + + + return response \ No newline at end of file diff --git a/python/tests/fake_server/setup.py b/python/tests/fake_server/setup.py index aa92527..34fe21c 100644 --- a/python/tests/fake_server/setup.py +++ b/python/tests/fake_server/setup.py @@ -1,21 +1,593 @@ from tests.fake_server.data import FakeServerData +import json +import re +import sys +from werkzeug.wrappers import Response +from teams_lib_pzsp2_z1.model.chat import ChatType +import traceback def setup_fake_server(httpserver) -> FakeServerData: """ Sets up a fake HTTP server to simulate Microsoft Graph API responses. + Includes stdout logging for debugging. """ data = FakeServerData() - # Mock response for listing joined teams + def make_log_response(request, data_dict: dict, context_msg: str = "", status_code = None) -> Response: + print(f"\n[MOCK SERVER] 🟢 REQUEST: {request.method} {request.path}", file=sys.stdout) + if context_msg: + print(f"[MOCK SERVER] Context: {context_msg}", file=sys.stdout) + + print(f"[MOCK SERVER] Returned body: {data_dict}", file=sys.stdout) + + if request.content_type == "application/json": + try: + req_data = request.json + if req_data: + print(f"[MOCK SERVER] Body: {req_data}", file=sys.stdout) + except Exception as e: + print(f"[MOCK SERVER] ⚠️ Body Error: {e}", file=sys.stdout) + + if not status_code: + status_code = 200 if data_dict else 404 + if request.method in ["POST", "PUT"] and data_dict: + status_code = 201 + + print(f"[MOCK SERVER] 👉 Response: {status_code}", file=sys.stdout) + + return Response( + json.dumps(data_dict), + mimetype="application/json", + status=status_code + ) + + # ========================================== + # SEARCH + # ========================================== + + # POST /search/query + httpserver.expect_request( + "/v1.0/search/query", + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_search_messages_response(req.json), + "Search Messages", + status_code=200 + )) + + # ========================================== + # USERS + # ========================================== + + # GET /users/me/joinedTeams httpserver.expect_request( "/v1.0/users/me-token-to-replace/joinedTeams", method="GET" - ).respond_with_json(data.get_myJoinedTeams_response()) + ).respond_with_handler(lambda req: make_log_response( + req, data.get_myJoinedTeams_response(), "List Joined Teams" + )) + + # GET /users/me/chats/getAllMessages() + httpserver.expect_request( + "/v1.0/users/me-token-to-replace/chats/getAllMessages()", + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_all_messeges_in_chats_response(), + "List All Chat Messages" + )) + + # GET users/me/chats/{chat_id}/messages + httpserver.expect_request( + re.compile(r"^/v1.0/users/me-token-to-replace/chats/([^/]+)/messages"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_list_messages_in_chat_response( + re.search(r"/users/me-token-to-replace/chats/([^/]+)/messages", req.path).group(1) + ), + "List Chat Messages (me)" + )) + + # DELETE users/me/chats/{chat_id}/messages/{message_id}/softDelete + httpserver.expect_request( + re.compile(r"/v1.0/users/[^/]+/chats/[^/]+/messages/[^/]+/softDelete"), + method="POST" + ).respond_with_response( + Response(status=204) + ) + + def handle_chats_request(req): + filter_param = req.args.get('$filter', '') + chat_type = None + match = re.search(r"chatType\s+eq\s+'([^']+)'", filter_param) + + if match: + chat_type = match.group(1) + + if chat_type == "oneOnOne": + chat_type = ChatType.ONE_ON_ONE + elif chat_type == "group": + chat_type = ChatType.GROUP + + return make_log_response( + req, + data.get_list_chats_response(chat_type), + f"List Chats ({chat_type})" + ) + + # GET /users/me/chats + httpserver.expect_request( + "/v1.0/users/me-token-to-replace/chats", + method="GET" + ).respond_with_handler(handle_chats_request) + + # GET /users/me + httpserver.expect_request( + "/v1.0/users/me-token-to-replace", + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_me_response(), + "Get Users" + )) + + # ========================================== + # TEAMS + # ========================================== + + # POST /teams/{id}/channels/{channel_id}/members + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/members"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_add_member_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/members", req.path).group(1), + req.json + ), + "Add member to channel" + )) + + # PATCH /teams/{tid}/channels/{cid}/members/{mid} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/members/([^/]+)"), + method="PATCH" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_update_member_role_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/members/([^/]+)", req.path).group(1), # team_id + re.search(r"/teams/([^/]+)/channels/([^/]+)/members/([^/]+)", req.path).group(2), # channel_id + re.search(r"/teams/([^/]+)/channels/([^/]+)/members/([^/]+)", req.path).group(3), # member_id + req.json + ), + "Update Member Role" + )) + + # DELETE /teams/{team_id}/channels/{channel_id}/members/{member_id} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/members/([^/]+)"), + method="DELETE" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_remove_member_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/members/([^/]+)", req.path).group(1), # team_id + re.search(r"/teams/([^/]+)/channels/([^/]+)/members/([^/]+)", req.path).group(2), # channel_id + re.search(r"/teams/([^/]+)/channels/([^/]+)/members/([^/]+)", req.path).group(3) # member_id + ), + "Remove Member from Channel" + )) + + # GET /teams/{team_id}/channels/{channel_id}/members + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/members"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_list_members_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/members", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)/members", req.path).group(2) + ), + "List channel members" + )) + + # GET /teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies/{reply_id} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies/([^/]+)"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_reply_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies/([^/]+)", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies/([^/]+)", req.path).group(2), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies/([^/]+)", req.path).group(3), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies/([^/]+)", req.path).group(4) + ), + "Get Message Reply" + )) + + # GET /teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_list_replies_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies", req.path).group(2), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)/replies", req.path).group(3) + ), + "List Message Replies" + )) + + # GET /teams/{team_id}/channels/{channel_id}/messages/{message_id} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_message_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)", req.path).group(2), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages/([^/]+)", req.path).group(3) + ), + "Get Channel Message" + )) + + # POST /teams/{team_id}/channels/{channel_id}/messages + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/messages"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_send_message_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages", req.path).group(2), + req.json + ), + "Create Channel Message" + )) + + # GET /teams/{team_id}/channels/{channel_id}/messages + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)/messages"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_list_messages_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)/messages", req.path).group(2) + ), + "List Channel Messages" + )) + + # DELETE /teams/{team_id}/channels/{channel_id} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)"), + method="DELETE" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_delete_channel_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)", req.path).group(2) + ), + "Delete Channel (DELETE)" + )) + + # GET /teams/{team_id}/channels/{channel_id} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels/([^/]+)"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_channel_response( + re.search(r"/teams/([^/]+)/channels/([^/]+)", req.path).group(1), + re.search(r"/teams/([^/]+)/channels/([^/]+)", req.path).group(2) + ), + "Get Channel Details" + )) + + # POST /teams/{id}/channels + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_create_channel_response( + re.search(r"/teams/([^/]+)/channels", req.path).group(1), + req.json + ), + "Create Channel (POST)" + )) + + # GET /teams/{id}/channels + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/channels"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_listChannels_response(re.search(r"/teams/([^/]+)/channels", req.path).group(1)), + "List Channels" + )) + + # POST /teams/{id}/archive + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/archive"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_archiveTeam_response(re.search(r"/teams/([^/]+)/archive", req.path).group(1)), + "Archive Team (POST)" + )) + + # POST /teams/{id}/unarchive + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/unarchive"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_unarchiveTeam_response(re.search(r"/teams/([^/]+)/unarchive", req.path).group(1)), + "Unarchive Team (POST)" + )) + + # PATCH /teams/{id} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)"), + method="PATCH" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_updateTeam_response( + re.search(r"/teams/([^/]+)", req.path).group(1), + req.json + ), + "Update Team (PATCH)" + )) + + # PATCH /groups/{id} + httpserver.expect_request( + re.compile(r"^/v1.0/groups/([^/]+)"), + method="PATCH" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_updateTeam_response( + re.search(r"/groups/([^/]+)", req.path).group(1), + req.json + ), + "Update Team (PATCH)" + )) + + # GET /teams/{id} + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)(?:$|\?)"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_team_response(re.search(r"/teams/([^/?]+)", req.path).group(1)), + "Get Team Details" + )) + + # POST /teams + httpserver.expect_request( + "/v1.0/teams", + method="POST" + ).respond_with_handler(data.get_createTeamFromTemplate_response) + + # POST /teams/{team_id}/members + httpserver.expect_request( + re.compile(r"^/v1.0/teams/([^/]+)/members"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_add_member_response( + re.search(r"/teams/([^/]+)/members", req.path).group(1), + req.json + ), + "Add Team Member" + )) + + # ========================================== + # GROUPS + # ========================================== + + # PUT /groups/{id}/team + httpserver.expect_request( + re.compile(r"^/v1.0/groups/([^/]+)/team"), + method="PUT" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_createTeamViaGroup_response(re.search(r"/groups/([^/]+)/team", req.path).group(1)), + "Teamify Group (PUT)" + )) + + # DELETE /groups/{id} (Delete Team uses group endpoint) + httpserver.expect_request( + re.compile(r"^/v1.0/groups/([^/]+)"), + method="DELETE" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_deleteTeam_response(re.search(r"/groups/([^/]+)", req.path).group(1)), + "Delete Team (DELETE)" + )) + + # POST /groups + httpserver.expect_request( + "/v1.0/groups", + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_createGroup_response(req.json), + "Create Group" + )) + + # ========================================== + # DIRECTORY / OTHER + # ========================================== + + # POST /directory/deletedItems/{id}/restore + httpserver.expect_request( + re.compile(r"^/v1.0/directory/deletedItems/([^/]+)/restore"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_restoreTeam_response(re.search(r"/directory/deletedItems/([^/]+)/restore", req.path).group(1)), + "Restore Deleted Team (POST)" + )) - # Mock response for listing channels in the fake team + # ========================================== + # CHATS + # ========================================== + + + # DELETE /chats/{chat_id}/pinnedMessages/{message_id} + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/pinnedMessages/([^/]+)"), + method="DELETE" + ).respond_with_response( + Response(status=204) + ) + + # POST /chats/{chat_id}/pinnedMessages httpserver.expect_request( - f"/v1.0/teams/{data.teams[0].ID}/channels", method="GET" - ).respond_with_json(data.get_listChannels_response(data.teams[0].ID)) + re.compile(r"^/v1.0/chats/([^/]+)/pinnedMessages"), + method="POST" + ).respond_with_response( + Response(status=204) + ) + + # GET /chats/{chat_id}/pinnedMessages + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/pinnedMessages"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_list_pinned_messages_in_chat_response( + re.search(r"/chats/([^/]+)/pinnedMessages", req.path).group(1) + ), + "List Pinned Chat Messages" + )) + + # DELETE /chats/{chat_id}/members/{member_id} + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/members/([^/]+)"), + method="DELETE" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_remove_member_from_group_chat_response( + re.search(r"/chats/([^/]+)/members/([^/]+)", req.path).group(1), + re.search(r"/chats/([^/]+)/members/([^/]+)", req.path).group(2) + ), + "Remove Chat Member" + )) + + # POST /chats/{chat_id}/members + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/members"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_add_member_to_group_chat_response( + re.search(r"/chats/([^/]+)/members", req.path).group(1), + req.json + ), + "Add Chat Member" + )) + + # GET /chats/{chat_id}/members + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/members"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_list_group_chat_members_response( + re.search(r"/chats/([^/]+)/members", req.path).group(1) + ), + "List Chat Members" + )) + + # GET /chats/{chat_id}/messages/{message_id} + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/messages/([^/]+)"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_get_message_in_chat_response( + re.search(r"/chats/([^/]+)/messages/([^/]+)", req.path).group(1), + re.search(r"/chats/([^/]+)/messages/([^/]+)", req.path).group(2) + ), + "Get Chat Message" + )) + + # POST /chats/{chat_id}/messages + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/messages"), + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_send_message_in_chat_response( + re.search(r"/chats/([^/]+)/messages", req.path).group(1), + req.json + ), + "Send Chat Message" + )) + + # GET /chats/{chat_id}/messages + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)/messages"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_list_messages_in_chat_response( + re.search(r"/chats/([^/]+)/messages", req.path).group(1) + ), + "List Chat Messages" + )) + + # PATCH /chats/{chat_id} + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)"), + method="PATCH" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_update_group_chat_topic_response( + re.search(r"/chats/([^/]+)", req.path).group(1), + req.json + ), + "Update Chat" + )) + + # POST /chats + httpserver.expect_request( + "/v1.0/chats", + method="POST" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_create_chat_response(req.json), + "Create Chat" + )) + + # GET /chats/{chat_id} + httpserver.expect_request( + re.compile(r"^/v1.0/chats/([^/]+)"), + method="GET" + ).respond_with_handler(lambda req: make_log_response( + req, + data.get_chat_responses(re.search(r"/chats/([^/]+)", req.path).group(1)), + "Get Chat" + )) + - return data + # Fallback for unmatched routes + httpserver.expect_request(re.compile(".*")).respond_with_handler( + lambda req: ( + print(f"[MOCK SERVER] ⚠️ UNMATCHED: {req.method} {req.path}", file=sys.stdout), + print(f"[MOCK SERVER] Body: {req.get_data(as_text=True)}", file=sys.stdout), + Response( + json.dumps({"error": f"Route not matched in Mock: {req.method} {req.path}"}), + status=404, + mimetype="application/json" + ) + )[-1] + ) + return data \ No newline at end of file diff --git a/python/tests/test_channels.py b/python/tests/test_channels.py index 121f315..98323eb 100644 --- a/python/tests/test_channels.py +++ b/python/tests/test_channels.py @@ -1,6 +1,9 @@ from teams_lib_pzsp2_z1.client import TeamsClient from tests.init_fake_client import init_fake_client from tests.fake_server.setup import setup_fake_server +from teams_lib_pzsp2_z1.model.message import MessageContentType, MessageBody +from teams_lib_pzsp2_z1.model.mention import MentionKind +from teams_lib_pzsp2_z1.model.search import SearchConfig, SearchMessagesOptions def test_list_channels_integration(httpserver): @@ -15,17 +18,421 @@ def test_list_channels_integration(httpserver): try: init_fake_client(client, httpserver.url_for("")) - channels = client.channels.list_channels(data.teams[0].DisplayName) + channels = client.channels.list_channels(data.teams[0].display_name) - assert len(channels) == len(data.channels[data.teams[0].ID]) + assert len(channels) == len(data.channels[data.teams[0].id]) - assert channels[0].Name == data.channels[data.teams[0].ID][0].Name - assert channels[0].ID == data.channels[data.teams[0].ID][0].ID - assert channels[0].IsGeneral == data.channels[data.teams[0].ID][0].IsGeneral + assert channels[0].name == data.channels[data.teams[0].id][0].name + assert channels[0].id == data.channels[data.teams[0].id][0].id + assert channels[0].is_general == data.channels[data.teams[0].id][0].is_general - assert channels[1].Name == data.channels[data.teams[0].ID][1].Name - assert channels[1].ID == data.channels[data.teams[0].ID][1].ID - assert channels[1].IsGeneral == data.channels[data.teams[0].ID][1].IsGeneral + assert channels[1].name == data.channels[data.teams[0].id][1].name + assert channels[1].id == data.channels[data.teams[0].id][1].id + assert channels[1].is_general == data.channels[data.teams[0].id][1].is_general finally: client.close() + + +def test_get_channel_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + channel = client.channels.get( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][1].name, + ) + + assert channel.name == data.channels[data.teams[0].id][1].name + assert channel.id == data.channels[data.teams[0].id][1].id + assert channel.is_general == data.channels[data.teams[0].id][1].is_general + + finally: + client.close() + + +def test_create_standard_channel_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + channel = client.channels.create_standard( + team_ref=data.teams[0].display_name, + display_name=data.newChannelName, + ) + + assert channel.name == data.newChannelName + assert channel.id == data.newChannelID + assert channel.is_general is False + + finally: + client.close() + + +def test_create_private_channel_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + channel = client.channels.create_private( + team_ref=data.teams[0].display_name, + display_name=data.newChannelName, + member_refs=[], + owner_refs=[], + ) + + assert channel.name == data.newChannelName + assert channel.id == data.newChannelID + assert channel.is_general is False + + finally: + client.close() + + +def test_delete_channel_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + success = client.channels.delete( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][1].name, + ) + + assert success is True + + finally: + client.close() + + +def test_send_message_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + message = client.channels.send_message( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + body=MessageBody( + content=data.newMessageTemplate.content, + content_type=MessageContentType.TEXT, + mentions=[], + ) + ) + + assert message.id == data.newMessageTemplate.id + assert message.content == data.newMessageTemplate.content + assert message.content_type == MessageContentType(data.newMessageTemplate.content_type) + assert message.sender.user_id == data.newMessageTemplate.sender.user_id + assert message.sender.display_name == data.newMessageTemplate.sender.display_name + assert message.reply_count == data.newMessageTemplate.reply_count + assert message.created_date_time == data.newMessageTemplate.created_date_time + + finally: + client.close() + + +def test_list_messages_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + collection = client.channels.list_messages( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + ) + + assert len(collection.messages) == len(data.messages[data.channels[data.teams[0].id][0].id]) + assert collection.messages[0].id == data.messages[data.channels[data.teams[0].id][0].id][0].id + assert collection.messages[0].content == data.messages[data.channels[data.teams[0].id][0].id][0].content + assert collection.messages[0].content_type == MessageContentType(data.messages[data.channels[data.teams[0].id][0].id][0].content_type) + assert collection.messages[0].sender.user_id == data.messages[data.channels[data.teams[0].id][0].id][0].sender.user_id + assert collection.messages[0].sender.display_name == data.messages[data.channels[data.teams[0].id][0].id][0].sender.display_name + assert collection.messages[0].reply_count == data.messages[data.channels[data.teams[0].id][0].id][0].reply_count + assert collection.messages[0].created_date_time == data.messages[data.channels[data.teams[0].id][0].id][0].created_date_time + + assert collection.messages[1].id == data.messages[data.channels[data.teams[0].id][0].id][1].id + assert collection.messages[1].content == data.messages[data.channels[data.teams[0].id][0].id][1].content + assert collection.messages[1].content_type == MessageContentType(data.messages[data.channels[data.teams[0].id][0].id][1].content_type) + assert collection.messages[1].sender.user_id == data.messages[data.channels[data.teams[0].id][0].id][1].sender.user_id + assert collection.messages[1].sender.display_name == data.messages[data.channels[data.teams[0].id][0].id][1].sender.display_name + assert collection.messages[1].reply_count == data.messages[data.channels[data.teams[0].id][0].id][1].reply_count + assert collection.messages[1].created_date_time == data.messages[data.channels[data.teams[0].id][0].id][1].created_date_time + finally: + client.close() + + +def test_get_message_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + message = client.channels.get_message( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + message_id=data.messages[data.channels[data.teams[0].id][0].id][0].id, + ) + + assert message.id == data.messages[data.channels[data.teams[0].id][0].id][0].id + assert message.content == data.messages[data.channels[data.teams[0].id][0].id][0].content + assert message.content_type == MessageContentType(data.messages[data.channels[data.teams[0].id][0].id][0].content_type) + assert message.sender.user_id == data.messages[data.channels[data.teams[0].id][0].id][0].sender.user_id + assert message.sender.display_name == data.messages[data.channels[data.teams[0].id][0].id][0].sender.display_name + assert message.reply_count == data.messages[data.channels[data.teams[0].id][0].id][0].reply_count + assert message.created_date_time == data.messages[data.channels[data.teams[0].id][0].id][0].created_date_time + + finally: + client.close() + + +def test_list_replies_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + replies = client.channels.list_message_replies( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + message_id=data.messages[data.channels[data.teams[0].id][0].id][1].id, + ) + + assert len(replies.messages) == len(data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id]) + assert replies.messages[0].id == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].id + assert replies.messages[0].content == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].content + assert replies.messages[0].content_type == MessageContentType(data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].content_type) + assert replies.messages[0].sender.user_id == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].sender.user_id + assert replies.messages[0].sender.display_name == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].sender.display_name + assert replies.messages[0].created_date_time == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].created_date_time + + assert replies.messages[1].id == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][1].id + assert replies.messages[1].content == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][1].content + assert replies.messages[1].content_type == MessageContentType(data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][1].content_type) + assert replies.messages[1].sender.user_id == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][1].sender.user_id + assert replies.messages[1].sender.display_name == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][1].sender.display_name + assert replies.messages[1].created_date_time == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][1].created_date_time + + finally: + client.close() + + +def test_get_reply_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + reply = client.channels.get_message_reply( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + message_id=data.messages[data.channels[data.teams[0].id][0].id][1].id, + reply_id=data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].id, + ) + + assert reply.id == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].id + assert reply.content == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].content + assert reply.content_type == MessageContentType(data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].content_type) + assert reply.sender.user_id == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].sender.user_id + assert reply.sender.display_name == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].sender.display_name + assert reply.created_date_time == data.replies[data.messages[data.channels[data.teams[0].id][0].id][1].id][0].created_date_time + + finally: + client.close() + + +def test_list_members_integration(httpserver): + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + team_id = data.teams[0].id + channel = data.channels[team_id][0] + channel_id = channel.id + + members = client.channels.list_members( + team_ref=data.teams[0].display_name, + channel_ref=channel.name, + ) + + expected_members = data.members[team_id][channel_id] + + assert len(members) == len(expected_members) + + assert members[0].user_id == expected_members[0].user_id + assert members[0].display_name == expected_members[0].display_name + expected_role_0 = "owner" if expected_members[0].role == "owner" else "" + assert members[0].role == expected_role_0 + assert members[0].email == expected_members[0].email + assert members[0].id == expected_members[0].id + + assert members[1].user_id == expected_members[1].user_id + assert members[1].display_name == expected_members[1].display_name + expected_role_1 = "owner" if expected_members[1].role == "owner" else "" + assert members[1].role == expected_role_1 + assert members[1].email == expected_members[1].email + assert members[1].id == expected_members[1].id + + finally: + client.close() + + +def test_add_member_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + member = client.channels.add_member( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][1].name, + user_ref=data.newMemberTemplate.display_name, + is_owner=True if data.newMemberTemplate.role == "owner" else False, + ) + + assert member.user_id == data.newMemberTemplate.user_id + assert member.display_name == data.newMemberTemplate.display_name + assert member.role == data.newMemberTemplate.role + assert member.email == data.newMemberTemplate.email + assert member.id == data.newMemberTemplate.id + + finally: + client.close() + + +def test_update_member_role_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + member = client.channels.update_member_role( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + user_ref=data.members[data.teams[0].id][data.channels[data.teams[0].id][0].id][1].email, + is_owner=True + ) + + assert member.user_id == data.members[data.teams[0].id][data.channels[data.teams[0].id][0].id][1].user_id + assert member.display_name == data.members[data.teams[0].id][data.channels[data.teams[0].id][0].id][1].display_name + assert member.role == "owner" + assert member.email == data.members[data.teams[0].id][data.channels[data.teams[0].id][0].id][1].email + assert member.id == data.members[data.teams[0].id][data.channels[data.teams[0].id][0].id][1].id + finally: + client.close() + + +def test_remove_member_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + success = client.channels.remove_member( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + user_ref=data.members[data.teams[0].id][data.channels[data.teams[0].id][0].id][1].email, + ) + + assert success is True + + finally: + client.close() + +def test_get_mention_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + mention = client.channels.get_mentions( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + raw_mentions=MentionKind.TEAM.value, + ) + + assert mention[0].kind == MentionKind.TEAM + assert mention[0].at_id == 0 + assert mention[0].text == data.teams[0].display_name + assert mention[0].target_id == data.teams[0].id + + finally: + client.close() + + +def test_search_messages_integration(httpserver): + + data = setup_fake_server(httpserver) + data.searching = "channels" + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + results = client.channels.search_messages( + team_ref=data.teams[0].display_name, + channel_ref=data.channels[data.teams[0].id][0].name, + options=SearchMessagesOptions( + query="Hello", + ), + config=SearchConfig( + max_workers=4, + ) + ) + + assert len(results.messages) == 1 + + assert results.messages[0].team_id == data.teams[0].id + assert results.messages[0].channel_id == data.channels[data.teams[0].id][0].id + assert results.messages[0].chat_id is None + + assert results.messages[0].message.id == data.messages[results.messages[0].channel_id][2].id + assert results.messages[0].message.content == data.messages[results.messages[0].channel_id][2].content + assert results.messages[0].message.content_type == MessageContentType(data.messages[results.messages[0].channel_id][2].content_type) + assert results.messages[0].message.sender.user_id == data.messages[results.messages[0].channel_id][2].sender.user_id + assert results.messages[0].message.sender.display_name == data.messages[results.messages[0].channel_id][2].sender.display_name + assert results.messages[0].message.reply_count == data.messages[results.messages[0].channel_id][2].reply_count + assert results.messages[0].message.created_date_time == data.messages[results.messages[0].channel_id][0].created_date_time + + finally: + client.close() + + + + + diff --git a/python/tests/test_chats.py b/python/tests/test_chats.py new file mode 100644 index 0000000..d71275b --- /dev/null +++ b/python/tests/test_chats.py @@ -0,0 +1,511 @@ +from teams_lib_pzsp2_z1.client import TeamsClient +from tests.init_fake_client import init_fake_client +from tests.fake_server.setup import setup_fake_server +from teams_lib_pzsp2_z1.model.chat import ChatType, ChatRef +from teams_lib_pzsp2_z1.model.message import MessageContentType, MessageBody +from teams_lib_pzsp2_z1.model.mention import MentionKind +from teams_lib_pzsp2_z1.model.search import SearchConfig, SearchMessagesOptions +from datetime import datetime + + +def test_list_my_group_chats_integration(httpserver): + """ + Integration test: Python -> Go Binary -> Fake HTTP -> Python Mock Server + """ + + data = setup_fake_server(httpserver) + + # Init fake client + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + chats = client.chats.list_my_joined(ChatType.GROUP) + + assert len(chats) == len(data.group_chats) + assert chats[0].id == data.group_chats[0].id + assert chats[0].type == data.group_chats[0].type + assert chats[0].topic == data.group_chats[0].topic + assert chats[0].is_hidden == data.group_chats[0].is_hidden + + assert chats[1].id == data.group_chats[1].id + assert chats[1].type == data.group_chats[1].type + assert chats[1].topic == data.group_chats[1].topic + assert chats[1].is_hidden == data.group_chats[1].is_hidden + + finally: + client.close() + +def test_list_my_one_on_one_chats_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + chats = client.chats.list_my_joined(ChatType.ONE_ON_ONE) + + assert len(chats) == len(data.oneonone_chats) + assert chats[0].id == data.oneonone_chats[0].id + assert chats[0].type == data.oneonone_chats[0].type + assert chats[0].topic == data.oneonone_chats[0].topic + assert chats[0].is_hidden == data.oneonone_chats[0].is_hidden + + finally: + client.close() + + +def test_create_group_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + chat = client.chats.create_group_chat( + topic=data.newChatTemplate.topic, + recipient_refs=[ + data.users[0].email, + data.users[1].email, + ], + include_me=True, + ) + + assert chat.id == data.newChatTemplate.id + assert chat.type == ChatType.GROUP + assert chat.topic == data.newChatTemplate.topic + assert chat.is_hidden == data.newChatTemplate.is_hidden + + finally: + client.close() + +def test_create_one_on_one_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + chat = client.chats.create_one_on_one( + recipient_ref=data.users[0].email, + ) + + assert chat.id == data.newChatTemplate.id + assert chat.type == ChatType.ONE_ON_ONE + assert chat.topic == None + assert chat.is_hidden == data.newChatTemplate.is_hidden + + finally: + client.close() + +def test_get_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + chat = client.chats.get_chat( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ) + ) + + assert chat.id == data.group_chats[0].id + assert chat.type == data.group_chats[0].type + assert chat.topic == data.group_chats[0].topic + assert chat.is_hidden == data.group_chats[0].is_hidden + + finally: + client.close() + + +def test_list_group_chat_members_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + members = client.chats.list_group_chat_members(data.group_chats[0].topic) + + assert len(members) == len(data.group_chat_members[data.group_chats[0].id]) + + assert members[0].id == data.group_chat_members[data.group_chats[0].id][0].id + assert members[0].user_id == data.group_chat_members[data.group_chats[0].id][0].user_id + assert members[0].display_name == data.group_chat_members[data.group_chats[0].id][0].display_name + if data.group_chat_members[data.group_chats[0].id][0].role == "owner": + assert members[0].role == "owner" + assert members[0].email == data.group_chat_members[data.group_chats[0].id][0].email + + assert members[1].id == data.group_chat_members[data.group_chats[0].id][1].id + assert members[1].user_id == data.group_chat_members[data.group_chats[0].id][1].user_id + assert members[1].display_name == data.group_chat_members[data.group_chats[0].id][1].display_name + if data.group_chat_members[data.group_chats[0].id][1].role == "owner": + assert members[1].role == "owner" + assert members[1].email == data.group_chat_members[data.group_chats[0].id][1].email + + finally: + client.close() + + +def test_add_group_chat_member_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + member = client.chats.add_member_to_group_chat( + group_chat_ref=data.group_chats[0].topic, + user_ref=data.newMemberTemplate.email, + ) + + assert member.id == data.newMemberTemplate.id + assert member.user_id == data.newMemberTemplate.user_id + assert member.display_name == data.newMemberTemplate.display_name + assert member.role == data.newMemberTemplate.role + assert member.email == data.newMemberTemplate.email + finally: + client.close() + + +def test_remove_group_chat_member_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + result = client.chats.remove_member_from_group_chat( + group_chat_ref=data.group_chats[0].topic, + member_ref=data.group_chat_members[data.group_chats[0].id][0].email, + ) + + assert result is True + + finally: + client.close() + + +def test_update_group_chat_topic_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + chat = client.chats.update_group_chat_topic( + group_chat_ref=data.group_chats[0].topic, + new_topic=data.updatedGroupChatTopic, + ) + + assert chat.id == data.group_chats[0].id + assert chat.type == data.group_chats[0].type + assert chat.topic == data.updatedGroupChatTopic + assert chat.is_hidden == data.group_chats[0].is_hidden + + finally: + client.close() + + +def test_list_messeges_in_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + collection = client.chats.list_messages( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ) + ) + + assert len(collection.messages) == len(data.chat_messages[data.group_chats[0].id]) + + assert collection.messages[0].id == data.chat_messages[data.group_chats[0].id][0].id + assert collection.messages[0].content == data.chat_messages[data.group_chats[0].id][0].content + assert collection.messages[0].content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][0].content_type) + assert collection.messages[0].sender.user_id == data.chat_messages[data.group_chats[0].id][0].sender.user_id + assert collection.messages[0].sender.display_name == data.chat_messages[data.group_chats[0].id][0].sender.display_name + assert collection.messages[0].reply_count == data.chat_messages[data.group_chats[0].id][0].reply_count + assert collection.messages[0].created_date_time == data.chat_messages[data.group_chats[0].id][0].created_date_time + + assert collection.messages[1].id == data.chat_messages[data.group_chats[0].id][1].id + assert collection.messages[1].content == data.chat_messages[data.group_chats[0].id][1].content + assert collection.messages[1].content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][1].content_type) + assert collection.messages[1].sender.user_id == data.chat_messages[data.group_chats[0].id][1].sender.user_id + assert collection.messages[1].sender.display_name == data.chat_messages[data.group_chats[0].id][1].sender.display_name + assert collection.messages[1].reply_count == data.chat_messages[data.group_chats[0].id][1].reply_count + assert collection.messages[1].created_date_time == data.chat_messages[data.group_chats[0].id][1].created_date_time + + finally: + client.close() + +def test_send_message_in_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + message = client.chats.send_message( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ), + body=MessageBody( + content=data.newMessageTemplate.content, + content_type=MessageContentType(data.newMessageTemplate.content_type), + mentions=[], + ) + ) + + assert message.id == data.newMessageTemplate.id + assert message.content == data.newMessageTemplate.content + assert message.content_type == MessageContentType(data.newMessageTemplate.content_type) + assert message.sender.user_id == data.newMessageTemplate.sender.user_id + assert message.sender.display_name == data.newMessageTemplate.sender.display_name + assert message.reply_count == data.newMessageTemplate.reply_count + assert message.created_date_time == data.newMessageTemplate.created_date_time + + finally: + client.close() + +def test_delete_message_in_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + result = client.chats.delete_message( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ), + message_id=data.chat_messages[data.group_chats[0].id][0].id, + ) + + assert result is True + + finally: + client.close() + +def test_get_message_in_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + message = client.chats.get_message( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ), + message_id=data.chat_messages[data.group_chats[0].id][0].id, + ) + + assert message.id == data.chat_messages[data.group_chats[0].id][0].id + assert message.content == data.chat_messages[data.group_chats[0].id][0].content + assert message.content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][0].content_type) + assert message.sender.user_id == data.chat_messages[data.group_chats[0].id][0].sender.user_id + assert message.sender.display_name == data.chat_messages[data.group_chats[0].id][0].sender.display_name + assert message.reply_count == data.chat_messages[data.group_chats[0].id][0].reply_count + assert message.created_date_time == data.chat_messages[data.group_chats[0].id][0].created_date_time + finally: + client.close() + + +def test_get_all_messages_in_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + messages = client.chats.list_all_messages( + start_time=datetime.min, + end_time=datetime.max, + top=50, + ) + + assert len(messages) == len(data.chat_messages[data.group_chats[0].id]) + + assert messages[0].id == data.chat_messages[data.group_chats[0].id][0].id + assert messages[0].content == data.chat_messages[data.group_chats[0].id][0].content + assert messages[0].content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][0].content_type) + assert messages[0].sender.user_id == data.chat_messages[data.group_chats[0].id][0].sender.user_id + assert messages[0].sender.display_name == data.chat_messages[data.group_chats[0].id][0].sender.display_name + assert messages[0].reply_count == 0 + assert messages[0].created_date_time == data.chat_messages[data.group_chats[0].id][0].created_date_time + + assert messages[1].id == data.chat_messages[data.group_chats[0].id][1].id + assert messages[1].content == data.chat_messages[data.group_chats[0].id][1].content + assert messages[1].content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][1].content_type) + assert messages[1].sender.user_id == data.chat_messages[data.group_chats[0].id][1].sender.user_id + assert messages[1].sender.display_name == data.chat_messages[data.group_chats[0].id][1].sender.display_name + assert messages[1].reply_count == 0 + assert messages[1].created_date_time == data.chat_messages[data.group_chats[0].id][1].created_date_time + + finally: + client.close() + + +def test_list_pinned_messages_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + messages = client.chats.list_pinned_messages( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ) + ) + + assert len(messages) == len(data.chat_messages[data.group_chats[0].id]) + + assert messages[0].id == data.chat_messages[data.group_chats[0].id][0].id + assert messages[0].content == data.chat_messages[data.group_chats[0].id][0].content + assert messages[0].content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][0].content_type) + assert messages[0].sender.user_id == data.chat_messages[data.group_chats[0].id][0].sender.user_id + assert messages[0].sender.display_name == data.chat_messages[data.group_chats[0].id][0].sender.display_name + assert messages[0].reply_count == 0 + assert messages[0].created_date_time == data.chat_messages[data.group_chats[0].id][0].created_date_time + + assert messages[1].id == data.chat_messages[data.group_chats[0].id][1].id + assert messages[1].content == data.chat_messages[data.group_chats[0].id][1].content + assert messages[1].content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][1].content_type) + assert messages[1].sender.user_id == data.chat_messages[data.group_chats[0].id][1].sender.user_id + assert messages[1].sender.display_name == data.chat_messages[data.group_chats[0].id][1].sender.display_name + assert messages[1].reply_count == 0 + assert messages[1].created_date_time == data.chat_messages[data.group_chats[0].id][1].created_date_time + + finally: + client.close() + + +def test_pin_message_in_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + result = client.chats.pin_message( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ), + message_id=data.chat_messages[data.group_chats[0].id][0].id, + ) + + assert result is True + + finally: + client.close() + + +def test_unpin_message_in_chat_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + result = client.chats.unpin_message( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ), + message_id=data.chat_messages[data.group_chats[0].id][0].id, + ) + + assert result is True + + finally: + client.close() + + +def test_get_mention_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + mention = client.chats.get_mentions( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ), + raw_mentions=MentionKind.EVERYONE.value, + ) + + assert mention[0].kind == MentionKind.EVERYONE.value + assert mention[0].at_id == 0 + assert mention[0].text == "Everyone" + assert mention[0].target_id == data.group_chats[0].id + + finally: + client.close() + +def test_search_messages_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + results = client.chats.search_messages( + chat_ref=ChatRef( + ref=data.group_chats[0].topic, + type=ChatType.GROUP, + ), + options=SearchMessagesOptions( + query="Hello" + ), + config=SearchConfig( + max_workers=4, + ) + ) + + assert len(results.messages) == 1 + + assert results.messages[0].chat_id == data.group_chats[0].id + + assert results.messages[0].message.id == data.chat_messages[data.group_chats[0].id][0].id + assert results.messages[0].message.content == data.chat_messages[data.group_chats[0].id][0].content + assert results.messages[0].message.content_type == MessageContentType(data.chat_messages[data.group_chats[0].id][0].content_type) + assert results.messages[0].message.sender.user_id == data.chat_messages[data.group_chats[0].id][0].sender.user_id + assert results.messages[0].message.sender.display_name == data.chat_messages[data.group_chats[0].id][0].sender.display_name + assert results.messages[0].message.reply_count == data.chat_messages[data.group_chats[0].id][0].reply_count + assert results.messages[0].message.created_date_time == data.chat_messages[data.group_chats[0].id][0].created_date_time + + finally: + client.close() diff --git a/python/tests/test_teams.py b/python/tests/test_teams.py new file mode 100644 index 0000000..be19e85 --- /dev/null +++ b/python/tests/test_teams.py @@ -0,0 +1,196 @@ +from teams_lib_pzsp2_z1.model.team import UpdateTeam +from teams_lib_pzsp2_z1.client import TeamsClient +from tests.init_fake_client import init_fake_client +from tests.fake_server.setup import setup_fake_server + + +def test_list_my_teams_integration(httpserver): + """ + Integration test: Python -> Go Binary -> Fake HTTP -> Python Mock Server + """ + + data = setup_fake_server(httpserver) + + # Init fake client + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + teams = client.teams.list_my_joined() + + assert len(teams) == len(data.teams) + + assert teams[0].display_name == data.teams[0].display_name + assert teams[0].id == data.teams[0].id + assert teams[0].description == data.teams[0].description + assert teams[0].is_archived == data.teams[0].is_archived + assert teams[0].visibility == data.teams[0].visibility + + assert teams[1].display_name == data.teams[1].display_name + assert teams[1].id == data.teams[1].id + assert teams[1].description == data.teams[1].description + assert teams[1].is_archived == data.teams[1].is_archived + assert teams[1].visibility == data.teams[1].visibility + + finally: + client.close() + +def test_get_team_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + team = client.teams.get(data.teams[0].display_name) + + print(data.get_team_response(data.teams[0].id)) + + assert team.display_name == data.teams[0].display_name + assert team.id == data.teams[0].id + assert team.description == data.teams[0].description + assert team.is_archived == data.teams[0].is_archived + assert team.visibility == data.teams[0].visibility + + finally: + client.close() + + +def test_update_team_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + updated_description = "Updated team description" + team = client.teams.update( + team_ref=data.teams[0].display_name, + update=UpdateTeam( + description=updated_description, + ), + ) + + assert team.display_name == data.teams[0].display_name + assert team.id == data.teams[0].id + assert team.description == updated_description + assert team.is_archived == data.teams[0].is_archived + assert team.visibility == data.teams[0].visibility + + finally: + client.close() + + +def test_create_team_via_group_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + team = client.teams.create_via_group( + display_name=data.newTeamName, + mail_nickname=data.newGroupMailNickname, + visibility=data.newTeamVisibility, + ) + + assert team.id == data.newGroupID + assert team.display_name == data.newTeamName + assert team.visibility == data.newTeamVisibility + + finally: + client.close() + + +def test_archive_team_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + success = client.teams.archive( + team_ref=data.teams[0].display_name, + spo_read_only_from_members=True, + ) + + assert success is True + + finally: + client.close() + + +def test_unarchive_team_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + success = client.teams.unarchive( + team_ref=data.teams[2].display_name, + ) + + assert success is True + + finally: + client.close() + + +def test_delete_team_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + success = client.teams.delete( + team_ref=data.teams[0].display_name, + ) + + assert success is True + + finally: + client.close() + + +def test_restore_deleted_team_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + msg = client.teams.restore_deleted( + deleted_team_ID=data.teams[2].id, + ) + + assert msg == data.teams[2].id + + finally: + client.close() + + +def test_error_handling_integration(httpserver): + + data = setup_fake_server(httpserver) + + client = TeamsClient(auto_init=False) + try: + init_fake_client(client, httpserver.url_for("")) + + try: + client.teams.get("non-existent-team") + assert False, "Expected an exception for non-existent team" + except Exception as e: + assert "Go Error" in str(e) + + finally: + client.close() \ No newline at end of file