diff --git a/.gitattributes b/.gitattributes index 96c2e0d..f66c783 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,7 @@ *.mof text eol=crlf *.sh text eol=lf *.svg eol=lf +*.yml text eol=lf # Ensure any exe files are treated as binary *.exe binary diff --git a/.github/PSModule.yml b/.github/PSModule.yml index b37f9f1..f260e68 100644 --- a/.github/PSModule.yml +++ b/.github/PSModule.yml @@ -1,6 +1,14 @@ # This file is used to configure the Process-PSModule workflow. # Reference: -# - https://github.com/PSModule/Process-PSModule?tab=readme-ov-file#configuration +# - https://psmodule.io/Process-PSModule/guides/configuring-the-pipeline/ + +ImportantFilePatterns: + - '^src/' + - '^README\.md$' + - '^tests/' + - '^\.github/PSModule\.yml$' + - '^\.github/workflows/' + - '^\.github/zensical\.toml$' Test: CodeCoverage: @@ -20,6 +28,14 @@ Test: # Docs: # Skip: true +Publish: + Module: + MajorLabels: 'release:major, major, breaking' + MinorLabels: 'release:minor, minor, feature' + PatchLabels: 'release:patch, patch, fix' + IgnoreLabels: 'release:skip, NoRelease' + PrereleaseLabels: 'release:prerelease, prerelease' + Linter: env: VALIDATE_BIOME_FORMAT: false diff --git a/.github/mkdocs.yml b/.github/mkdocs.yml deleted file mode 100644 index df5e17a..0000000 --- a/.github/mkdocs.yml +++ /dev/null @@ -1,75 +0,0 @@ -site_name: -{{ REPO_NAME }}- -theme: - name: material - language: en - font: - text: Roboto - code: Sono - logo: Assets/icon.png - favicon: Assets/icon.png - palette: - # Palette toggle for automatic mode - - media: "(prefers-color-scheme)" - toggle: - icon: material/link - name: Switch to dark mode - # Palette toggle for dark mode - - media: '(prefers-color-scheme: dark)' - scheme: slate - toggle: - primary: black - accent: green - icon: material/toggle-switch-off-outline - name: Switch to light mode - # Palette toggle for light mode - - media: '(prefers-color-scheme: light)' - scheme: default - toggle: - primary: indigo - accent: green - icon: material/toggle-switch - name: Switch to system preference - icon: - repo: material/github - features: - - navigation.instant - - navigation.instant.progress - - navigation.indexes - - navigation.top - - navigation.tracking - - navigation.expand - - search.suggest - - search.highlight - -repo_name: -{{ REPO_OWNER }}-/-{{ REPO_NAME }}- -repo_url: https://github.com/-{{ REPO_OWNER }}-/-{{ REPO_NAME }}- - -plugins: - - search - -markdown_extensions: - - toc: - permalink: true # Adds a link icon to headings - - attr_list - - admonition - - md_in_html - - pymdownx.details # Enables collapsible admonitions - -extra: - social: - - icon: fontawesome/brands/discord - link: https://discord.gg/jedJWCPAhD - name: -{{ REPO_OWNER }}- on Discord - - icon: fontawesome/brands/github - link: https://github.com/-{{ REPO_OWNER }}-/ - name: -{{ REPO_OWNER }}- on GitHub - consent: - title: Cookie consent - description: >- - We use cookies to recognize your repeated visits and preferences, as well - as to measure the effectiveness of our documentation and whether users - find what they're searching for. With your consent, you're helping us to - make our documentation better. - actions: - - accept - - reject diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 9462763..9418b44 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -4,6 +4,9 @@ on: workflow_dispatch: schedule: - cron: '0 0 * * *' + push: + branches: + - main pull_request: branches: - main @@ -13,19 +16,28 @@ on: - reopened - synchronize - labeled + - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: >- + ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} -permissions: - contents: write - pull-requests: write - statuses: write - pages: write - id-token: write +permissions: {} jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@205d193f34cbbaf9992955c21d842bcf98a1859f # v5.4.6 - secrets: inherit + permissions: + contents: read + pages: write + id-token: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} + TestData: >- + {"secrets":{"DISCORD_BOT_TOKEN":"${{ secrets.DISCORD_BOT_TOKEN }}"}, + "variables":{ + "DISCORD_TEST_GUILD_ID":${{ toJSON(vars.DISCORD_TEST_GUILD_ID) }}, + "DISCORD_TEST_CHANNEL_ID":${{ toJSON(vars.DISCORD_TEST_CHANNEL_ID) }}}} diff --git a/.github/zensical.toml b/.github/zensical.toml new file mode 100644 index 0000000..a9a8d42 --- /dev/null +++ b/.github/zensical.toml @@ -0,0 +1,109 @@ +[project] +site_name = "Discord" +site_url = "https://psmodule.io/Discord/" +docs_dir = "docs" +repo_name = "PSModule/Discord" +repo_url = "https://github.com/PSModule/Discord" + +[project.theme] +language = "en" +font.text = "Mona Sans" +font.code = "Source Code Pro" +logo = "Assets/icon.png" +favicon = "Assets/icon.png" +features = [ + "announce.dismiss", + "content.code.annotate", + "content.code.copy", + "content.tooltips", + "navigation.expand", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.instant.preview", + "navigation.instant.progress", + "navigation.tabs", + "navigation.top", + "navigation.tracking", + "search.highlight", + "search.share", + "search.suggest", + "toc.follow", +] + +[project.theme.icon] +repo = "material/github" +link = "material/link-variant" + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "material/link" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "black" +accent = "light blue" +toggle.icon = "material/toggle-switch-off-outline" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "black" +accent = "light blue" +toggle.icon = "material/toggle-switch" +toggle.name = "Switch to system preference" + +[project.plugins.meta] + +[project.plugins.search] + +[project.markdown_extensions.toc] +permalink = "#" + +[project.markdown_extensions.attr_list] + +[project.markdown_extensions.abbr] + +[project.markdown_extensions.admonition] + +[project.markdown_extensions.def_list] + +[project.markdown_extensions.footnotes] + +[project.markdown_extensions.md_in_html] + +[project.markdown_extensions.tables] + +[project.markdown_extensions.pymdownx.details] + +[project.markdown_extensions.pymdownx.superfences] +custom_fences = [ + { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }, +] + +[project.markdown_extensions.pymdownx.tasklist] +custom_checkbox = true + +[project.extra.consent] +title = "Cookie consent" +description = """ +We use cookies to recognize your repeated visits and preferences, as well +as to measure the effectiveness of our documentation and whether users +find what they're searching for. With your consent, you're helping us to +make our documentation better. +""" +actions = ["accept", "reject"] + +[[project.extra.social]] +icon = "fontawesome/brands/discord" +link = "https://discord.gg/jedJWCPAhD" +name = "PSModule on Discord" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/PSModule/" +name = "PSModule on GitHub" diff --git a/.gitignore b/.gitignore index 456ca0f..0f00402 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # PSModule framework outputs folder outputs/* +.PSModule/ # .Net build output bin/ diff --git a/README.md b/README.md index db8cb0c..2787e0a 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,260 @@ # Discord -A PowerShell module for interacting with Discord. +A PowerShell client for Discord, for people using the terminal and scripts using a bot. +The client provides authentication, encrypted saved contexts, the authenticated identity, +and a reusable REST command. It does not provide an MCP server, a Gateway client, +or server, channel, and message management commands. -## Prerequisites +## Requirements -This uses the following external resources: -- The [PSModule framework](https://github.com/PSModule) for building, testing and publishing the module. - -## Installation - -To install the module from the PowerShell Gallery, you can use the following command: +- PowerShell 7.4 or later. +- [Context](https://github.com/PSModule/Context) 8.1.3 or later, installed as a module dependency. +- A Discord application and the credentials described below. ```powershell Install-PSResource -Name Discord Import-Module -Name Discord ``` -## Usage +## Connect as a bot + +Create or select an application in the [Discord Developer Portal][applications]. +On its **Bot** page, obtain the bot token. The token is not the application's client +secret and is not a normal Discord account token. + +```powershell +$botToken = Read-Host -Prompt 'Bot token' -AsSecureString +Connect-Discord -BotToken $botToken +Get-DiscordViewer +``` -Here is a list of example that are typical use cases for the module. +The client validates the token with `GET /users/@me` before saving it. Installing +the bot in a server is not required for this identity lookup. Other REST operations +still require the bot's permissions and access to their resources. -### Example 1: Greet an entity +## Connect interactively as a user -Provide examples for typical commands that a user would like to do with the module. +Configure an application you control in the [Developer Portal][applications]: + +1. Copy the application ID, used as `ClientID`. +2. Obtain its **OAuth2 client secret**, not its bot token. +3. Under **OAuth2 > Redirects**, register exactly `http://localhost:53682/`. + The scheme, host, port, path, and trailing slash must match the value passed to the command. ```powershell -Greet-Entity -Name 'World' -Hello, World! +$clientSecret = Read-Host -Prompt 'OAuth2 client secret' -AsSecureString +$connection = @{ + ClientID = 'YOUR_APPLICATION_ID' + ClientSecret = $clientSecret + RedirectUri = 'http://localhost:53682/' +} +Connect-Discord @connection +Get-DiscordViewer ``` -### Example 2 +The command starts a temporary loopback HTTP listener, opens the authorization +page, validates the returned state and issuer, and exchanges the authorization +code using S256 PKCE and the client secret. The listener closes on success, +failure, or timeout. Use `-NoBrowser` to open the displayed authorization URL +yourself in a browser on the same machine. `-TimeoutSeconds` controls the wait +for consent and defaults to 180 seconds. + +The redirect URI must use HTTP on `localhost` or `127.0.0.1`, an explicit +nonprivileged port, and a path ending in `/`. If the port is occupied or the OS +does not permit the listener, select another port, register the new exact URI, +and pass it as `RedirectUri`. + +The default OAuth2 scope is only `identify`. Additional supported user scopes +can be supplied with `-Scope @('identify', 'guilds')`. Installation scopes such +as `bot` and `webhook.incoming` are not accepted by this user sign-in command. +User access is limited to the granted scopes and Discord's supported OAuth2 +endpoints. This is not a self-bot or normal-account session-token login. + +[Discord's authorization server metadata][metadata] advertises S256 PKCE and +public-client token authentication. This client deliberately uses the documented +confidential application flow and requires a client secret; it does not assume +that every Discord application qualifies for secretless sign-in. Do not distribute +your application's client secret to other users or embed it in scripts. + +## Saved contexts -Provide examples for typical commands that a user would like to do with the module. +Successful connections are saved in the Context vault `PSModule.Discord`. +Tokens, refresh tokens, and OAuth2 client secrets remain `SecureString` values +and are encrypted on disk by Context. Protect your local account and vault; +context objects and vault backups are sensitive even though normal output +does not display plaintext credentials. + +The most recent connection becomes the default unless `-NotDefault` is used. +The default context and its settings are restored in later PowerShell sessions +without another sign-in. OAuth2 access tokens are refreshed automatically +when they expire or have 30 seconds or less remaining, and rotated credentials +are saved back to the same context. + +```powershell +Connect-Discord -BotToken $botToken -Name 'automation' +Connect-Discord @connection -Name 'personal' -Scope @('identify', 'guilds') -NotDefault + +Get-DiscordContext +Get-DiscordContext -ListAvailable +Switch-DiscordContext -Context 'personal' +Get-DiscordViewer -Context 'automation' +``` + +Without a custom name, bot contexts are named `Bot-` and user contexts +are named `User--`. Discord user IDs are preserved as strings, +not rounded numbers. Different accounts and applications can coexist. ```powershell -Import-Module -Name PSModuleTemplate +Disconnect-Discord -Context 'personal' ``` -### Find more examples +Disconnect removes only that local saved context. It does not revoke the remote +grant or reset the bot token. Removing the default leaves no default selected; +use `Switch-DiscordContext` to choose another saved context. + +## Call the REST API + +```powershell +$result = Invoke-DiscordAPI -ApiEndpoint '/users/@me' +$result.Response +$result.StatusCode +$result.Headers + +$query = @{ limit = 10 } +Invoke-DiscordAPI -ApiEndpoint '/users/@me/guilds' -Body $query -Context 'personal' +``` + +GET and HEAD dictionary bodies become URL-encoded query parameters, with +Boolean values encoded as `true` or `false`. Other +dictionary and object bodies become JSON by default. The `Query` alias for +`Body` is available for GET requests. The client does not add GitHub pagination +parameters or collect additional pages automatically. + +Responses have `Request`, `Response`, `Headers`, `StatusCode`, and +`StatusDescription` properties. `Response` contains parsed JSON, or `$null` for +an empty response such as HTTP 204. `Request` contains only the method and URI, +not authorization headers, tokens, or request bodies. + +Bot contexts use `Authorization: Bot ...`; user contexts use `Bearer ...`. +Only HTTPS requests to `discord.com` under `/api/v10/` are accepted, including +when using the full `-Uri` parameter. Redirects are not followed. API failures +throw errors instead of returning success-shaped results. + +HTTP 429 responses are retried up to `RetryCount`, honoring Discord's +`retry_after` seconds or `Retry-After` header, including fractional seconds. +Other errors, including 401 and 403, are not retried. There is no cross-process +rate-limit scheduler. + +```powershell +Get-DiscordConfig +Set-DiscordConfig -RetryCount 3 -RetryInterval 1 -TimeoutSeconds 100 +``` + +`RetryInterval` is used only when a 429 response omits both delay fields, with a +warning. `TimeoutSeconds` in module configuration is the HTTP request timeout, +separate from the interactive consent timeout. + +## Commands + +| Command | Purpose | +| --- | --- | +| `Connect-DiscordAccount` (`Connect-Discord`) | Authenticate a bot or OAuth2 user and save its verified context. | +| `Disconnect-DiscordAccount` (`Disconnect-Discord`) | Remove a local saved context without revoking remote credentials. | +| `Get-DiscordContext` | Get the default, a named context, or all saved contexts. | +| `Switch-DiscordContext` | Select a saved context as the default. | +| `Get-DiscordViewer` | Retrieve the authenticated user or bot from `/users/@me`. | +| `Get-DiscordConfig` | Read client settings. | +| `Set-DiscordConfig` | Save client settings and the default context selection. | +| `Invoke-DiscordAPI` | Call the Discord REST API with a saved context or context object. | + +Use `Get-Help -Name -Examples` for more examples. Commands that change +local state, and REST calls that change remote state, support `-WhatIf`. + +## Development + +Source follows the [PSModule/GitHub](https://github.com/PSModule/GitHub) layout: +typed public classes, private module variables, grouped public and private +functions, a header, import-time initialization, and a framework manifest overlay. The +[PSModule framework](https://github.com/PSModule/Process-PSModule) builds and +packages the module. + +The framework builds and imports the module before running the tests. Tests do +not import or assemble the module themselves. Local contract tests exercise +public validation without replacing functions with mocks. Live integration +tests use real Discord credentials and read-only resources. + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = '.\tests' +$config.Run.Exit = $true +$config.Output.Verbosity = 'Detailed' +Invoke-Pester -Configuration $config +Invoke-ScriptAnalyzer -Path .\src -Recurse -Settings .\.github\linters\.powershell-psscriptanalyzer.psd1 +``` + +The template greeting `Get-PSModuleTest` is not part of the client command surface. + +### GitHub Actions credentials -To find more examples of how to use the module, please refer to the [examples](examples) folder. +The caller uses Process-PSModule `v8` and Pester 6.1 or later within the 6.x +release line. Its explicit `TestData` +payload exposes only the following values to the module test jobs: -Alternatively, you can use the Get-Command -Module 'This module' to find more commands that are available in the module. -To find examples of each of the commands you can use Get-Help -Examples 'CommandName'. +| GitHub setting | Kind | Purpose | +| --- | --- | --- | +| `DISCORD_BOT_TOKEN` | Repository secret | Required for unattended live bot authentication, identity, REST, and encrypted Context tests. | +| `DISCORD_TEST_GUILD_ID` | Repository variable | Optional existing server to read with the REST client. | +| `DISCORD_TEST_CHANNEL_ID` | Repository variable | Optional existing channel to read; it must belong to the configured server when both IDs are supplied. | -## Documentation +Invite the bot to the test server with permission to view the selected channel. +These tests do not send messages or create, edit, or delete Discord resources. +They create and clean up only their own local Context entries. Missing bot +credentials fail the live test setup with an actionable error; optional resource +tests are reported as skipped when their IDs are absent. -Link to further documentation if available, or describe where in the repository users can find more detailed documentation about -the module's functions and features. +#### Set up the test bot -## Contributing +1. Create a dedicated test application in the [Developer Portal][applications]. + On **Bot**, obtain its token. Resetting an existing token invalidates it for + every consumer, so avoid resetting a bot used elsewhere. +2. In this repository's **Settings > Secrets and variables > Actions > Secrets**, + save the token as `DISCORD_BOT_TOKEN`. Do not put it in a variable, source file, + or chat message. +3. To enable resource tests, use **OAuth2 > URL Generator** with the `bot` scope + and only **View Channels**. Open the generated URL and install the bot in the + test server using an account with **Manage Server**. Allow the bot to view the + test channel in its permission overrides. +4. Enable Discord **User Settings > Advanced > Developer Mode**, then copy the + server and channel IDs from their right-click menus. Add them to the repository's + Actions **Variables** as `DISCORD_TEST_GUILD_ID` and `DISCORD_TEST_CHANNEL_ID`. -Coder or not, you can contribute to the project! We welcome all contributions. +No Gateway process, Administrator permission, or privileged intents are needed. +The bot can appear offline while these REST tests run. -### For Users +The framework also needs its standard publishing and GitHub App credentials: +`PSGALLERY_API_KEY`, `SHELLY_CLIENT_ID`, and `SHELLY_PRIVATE_KEY`, supplied as +repository or available organization secrets. The old `APIKey` workflow input +is replaced by `PSGALLERY_API_KEY`. -If you don't code, you still sit on valuable information that can make this project even better. If you experience that the -product does unexpected things, throw errors or is missing functionality, you can help by submitting bugs and feature requests. -Please see the issues tab on this project and submit a new issue that matches your needs. +The existing `release:major`, `release:minor`, `release:patch`, `release:skip`, +and `release:prerelease` repository labels are explicitly mapped to the v8 +release settings. Legacy unprefixed labels remain accepted. -### For Developers +Initial user OAuth2 consent is interactive, not a CI login technique. A stored +user context supports unattended refresh after consent, but a disposable CI +runner cannot safely reuse a static refresh-token secret indefinitely when +Discord rotates it. Bot-based live CI avoids that rotation and matrix-concurrency +problem. User sign-in and consent remain a separate interactive check. -If you do code, we'd love to have your contributions. Please read the [Contribution guidelines](CONTRIBUTING.md) for more information. -You can either help by picking up an existing issue or submit a new one if you have an idea for a new feature or improvement. +## References -## Acknowledgements +- [Discord OAuth2][oauth2] +- [Authorization server metadata][metadata] +- [Current user and the `identify` scope][user] +- [Discord rate limits][rate-limits] -Here is a list of people and projects that helped this project in some way. +[applications]: https://discord.com/developers/applications +[oauth2]: https://docs.discord.com/developers/topics/oauth2 +[metadata]: https://discord.com/.well-known/oauth-authorization-server +[user]: https://docs.discord.com/developers/resources/user +[rate-limits]: https://docs.discord.com/developers/topics/rate-limits diff --git a/examples/General.ps1 b/examples/General.ps1 index e193423..b4c4b10 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -1,19 +1,33 @@ -<# - .SYNOPSIS - This is a general example of how to use the module. -#> +#Requires -Modules Discord + +<# + .SYNOPSIS + Connect a bot and read its Discord identity. -# Import the module -Import-Module -Name 'PSModule' + .DESCRIPTION + Save a bot context without changing the selected default, then use both the identity command and REST client. -# Define the path to the font file -$FontFilePath = 'C:\Fonts\CodeNewRoman\CodeNewRomanNerdFontPropo-Regular.tff' + .EXAMPLE + $token = Read-Host -Prompt 'Bot token' -AsSecureString + .\examples\General.ps1 -BotToken $token -# Install the font -Install-Font -Path $FontFilePath -Verbose + .OUTPUTS + System.Management.Automation.PSCustomObject + + The bot identity and its REST response. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + # A raw bot token entered securely, not a normal account token. + [Parameter(Mandatory)] + [securestring] $BotToken +) -# List installed fonts -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' +$ErrorActionPreference = 'Stop' -# Uninstall the font -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' | Uninstall-Font -Verbose +if ($PSCmdlet.ShouldProcess('Discord bot connection', 'Save context and read identity')) { + $context = Connect-Discord -BotToken $BotToken -NotDefault -PassThru + Get-DiscordViewer -Context $context + $result = Invoke-DiscordAPI -ApiEndpoint '/users/@me' -Context $context + $result.Response +} diff --git a/src/classes/public/Config/DiscordConfig.ps1 b/src/classes/public/Config/DiscordConfig.ps1 new file mode 100644 index 0000000..85784e7 --- /dev/null +++ b/src/classes/public/Config/DiscordConfig.ps1 @@ -0,0 +1,28 @@ +<# + .SYNOPSIS + Represent the persisted Discord client settings. +#> +class DiscordConfig { + [string] $ID = 'Module' + + [string] $DefaultContext = '' + + [ValidateRange(0, 10)] + [int] $RetryCount = 3 + + [ValidateRange(0, 86400)] + [double] $RetryInterval = 1 + + [ValidateRange(1, 3600)] + [int] $TimeoutSeconds = 100 + + DiscordConfig() {} + + DiscordConfig([pscustomobject] $Object) { + foreach ($name in @('DefaultContext', 'RetryCount', 'RetryInterval', 'TimeoutSeconds')) { + if ($null -ne $Object.PSObject.Properties[$name]) { + $this.$name = $Object.$name + } + } + } +} diff --git a/src/classes/public/Context/DiscordContext.ps1 b/src/classes/public/Context/DiscordContext.ps1 new file mode 100644 index 0000000..0b96ed3 --- /dev/null +++ b/src/classes/public/Context/DiscordContext.ps1 @@ -0,0 +1,63 @@ +<# + .SYNOPSIS + Represent a saved bot or OAuth2 user connection. + + .DESCRIPTION + ID is the Context vault key, not the Discord snowflake. UserID holds the authenticated identity. + Credentials remain SecureString values when a stored object is reconstructed. +#> +class DiscordContext { + [string] $ID + + [string] $Name + + [string] $DisplayName + + [ValidateSet('Bot', 'User')] + [string] $Type + + [string] $HostName = 'discord.com' + + [string] $ApiBaseUri = 'https://discord.com/api/v10' + + [string] $ApiVersion = '10' + + [ValidateSet('Bot', 'Bearer')] + [string] $AuthType + + [string] $UserID + + [string] $UserName + + [securestring] $Token + + [System.Nullable[datetime]] $TokenExpiresAt + + [securestring] $RefreshToken + + [string] $ClientID + + [securestring] $ClientSecret + + [uri] $RedirectUri + + [string[]] $Scope = @() + + DiscordContext() {} + + DiscordContext([pscustomobject] $Object) { + foreach ($name in @( + 'ID', 'Name', 'DisplayName', 'Type', 'HostName', 'ApiBaseUri', 'ApiVersion', + 'AuthType', 'UserID', 'UserName', 'Token', 'TokenExpiresAt', 'RefreshToken', + 'ClientID', 'ClientSecret', 'RedirectUri', 'Scope' + )) { + if ($null -ne $Object.PSObject.Properties[$name]) { + $this.$name = $Object.$name + } + } + } + + [string] ToString() { + return $this.Name + } +} diff --git a/src/functions/private/API/Assert-DiscordApiUri.ps1 b/src/functions/private/API/Assert-DiscordApiUri.ps1 new file mode 100644 index 0000000..bffc34f --- /dev/null +++ b/src/functions/private/API/Assert-DiscordApiUri.ps1 @@ -0,0 +1,34 @@ +function Assert-DiscordApiUri { + <# + .SYNOPSIS + Validate a destination before attaching credentials. + + .DESCRIPTION + Restrict the REST client to Discord's HTTPS v10 API, without user information or fragments. + + .EXAMPLE + Assert-DiscordApiUri -Uri 'https://discord.com/api/v10/users/@me' + + .OUTPUTS + None + + Unsafe destinations raise a terminating error. + + .LINK + https://psmodule.io/Discord/Functions/API/Invoke-DiscordAPI/ + #> + [OutputType([void])] + [CmdletBinding()] + param( + # The fully resolved destination. + [Parameter(Mandatory)] + [uri] $Uri + ) + + if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -cne 'https' -or + $Uri.DnsSafeHost -ine 'discord.com' -or $Uri.Port -ne 443 -or + $Uri.UserInfo -ne '' -or $Uri.Fragment -ne '' -or + -not $Uri.AbsolutePath.StartsWith('/api/v10/', [System.StringComparison]::Ordinal)) { + throw [System.ArgumentException]::new('Discord REST destinations must be HTTPS URLs under https://discord.com/api/v10/.') + } +} diff --git a/src/functions/private/API/ConvertTo-DiscordQueryString.ps1 b/src/functions/private/API/ConvertTo-DiscordQueryString.ps1 new file mode 100644 index 0000000..769bf89 --- /dev/null +++ b/src/functions/private/API/ConvertTo-DiscordQueryString.ps1 @@ -0,0 +1,44 @@ +function ConvertTo-DiscordQueryString { + <# + .SYNOPSIS + Encode a dictionary as URI query parameters. + + .DESCRIPTION + Preserve repeated values and use invariant formatting without modifying the input. + + .EXAMPLE + ConvertTo-DiscordQueryString -Query @{ limit = 10 } + + .OUTPUTS + System.String + + Percent-encoded query parameters without a leading question mark. + + .LINK + https://psmodule.io/Discord/Functions/API/Invoke-DiscordAPI/ + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The resolved query fields. + [Parameter(Mandatory)] + [System.Collections.IDictionary] $Query + ) + + $pairs = [System.Collections.Generic.List[string]]::new() + foreach ($key in ($Query.Keys | Sort-Object)) { + $encodedKey = [uri]::EscapeDataString([string] $key) + $values = @($Query[$key]) + if ($null -eq $Query[$key]) { + $values = @('') + } + foreach ($value in $values) { + $text = [System.Convert]::ToString($value, [cultureinfo]::InvariantCulture) + if ($value -is [bool]) { + $text = $text.ToLowerInvariant() + } + $pairs.Add("$encodedKey=$([uri]::EscapeDataString($text))") + } + } + $pairs -join '&' +} diff --git a/src/functions/private/API/Invoke-DiscordRequest.ps1 b/src/functions/private/API/Invoke-DiscordRequest.ps1 new file mode 100644 index 0000000..fe48f5d --- /dev/null +++ b/src/functions/private/API/Invoke-DiscordRequest.ps1 @@ -0,0 +1,193 @@ +function Invoke-DiscordRequest { + <# + .SYNOPSIS + Send a resolved Discord HTTP request. + + .DESCRIPTION + Handle JSON responses and bounded HTTP 429 retries. Never return credential-bearing transport objects. + + .EXAMPLE + Invoke-DiscordRequest -Uri $uri -Method GET -AuthType Bot -Token $token + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The sanitized request and parsed response envelope. + + .LINK + https://psmodule.io/Discord/Functions/API/Invoke-DiscordAPI/ + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + # The validated destination. + [Parameter(Mandatory)] + [uri] $Uri, + + # The resolved HTTP method. + [Parameter(Mandatory)] + [ValidateSet('GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS')] + [string] $Method, + + # The resolved authorization scheme; token exchange does not attach an access token. + [Parameter()] + [ValidateSet('Bot', 'Bearer')] + [string] $AuthType, + + # The access token, kept secure until the HTTP boundary. + [Parameter()] + [securestring] $Token, + + # An already serialized body, or a form dictionary whose secret fields are SecureStrings. + [Parameter()] + [AllowNull()] + [object] $Body, + + # The content type for the resolved body. + [Parameter()] + [string] $ContentType = 'application/json', + + # Additional attempts for HTTP 429 only. + [Parameter()] + [ValidateRange(0, 10)] + [int] $RetryCount = 0, + + # Fallback delay when Discord provides no retry delay. + [Parameter()] + [ValidateRange(0, 86400)] + [double] $RetryInterval = 1, + + # Per-request network timeout. + [Parameter()] + [ValidateRange(1, 3600)] + [int] $TimeoutSeconds = 100 + ) + + $headers = @{ Accept = 'application/json' } + $form = $null + try { + if ($null -ne $Token) { + $plainToken = ConvertFrom-SecureString -SecureString $Token -AsPlainText + if ([string]::IsNullOrWhiteSpace($plainToken) -or $plainToken -match '[\x00-\x20\x7f-\uffff]') { + throw [System.ArgumentException]::new('Supply a raw, nonempty Discord token without a scheme prefix or whitespace.') + } + if ([string]::IsNullOrEmpty($AuthType)) { + throw [System.ArgumentException]::new('An authentication scheme is required with an access token.') + } + $headers.Authorization = "$AuthType $plainToken" + $plainToken = $null + } + + $parameters = @{ + Uri = $Uri + Method = $Method + Headers = $headers + UserAgent = $script:UserAgent + ContentType = $ContentType + TimeoutSec = $TimeoutSeconds + MaximumRedirection = 0 + MaximumRetryCount = 0 + SkipHttpErrorCheck = $true + ErrorAction = 'Stop' + Verbose = $false + Debug = $false + } + if ($PSBoundParameters.ContainsKey('Body')) { + if ($Body -is [System.Collections.IDictionary]) { + $form = @{} + foreach ($key in $Body.Keys) { + if ($Body[$key] -is [securestring]) { + $form[$key] = ConvertFrom-SecureString -SecureString $Body[$key] -AsPlainText + } else { + $form[$key] = $Body[$key] + } + } + $parameters.Body = $form + } else { + $parameters.Body = $Body + } + } + + for ($attempt = 0; $attempt -le $RetryCount; $attempt++) { + Write-Verbose "Sending Discord $Method request." + try { + $response = Invoke-WebRequest @parameters + } catch [System.Net.Http.HttpRequestException], [System.Net.WebException], [System.Threading.Tasks.TaskCanceledException] { + $failure = $_ + $message = 'Discord HTTP transport failed ({0}). Check connectivity and the request timeout.' -f + $failure.Exception.GetType().Name + throw [System.Net.Http.HttpRequestException]::new($message) + } + + $status = [int] $response.StatusCode + $content = $response.Content + if ($content -is [byte[]]) { + $content = [System.Text.Encoding]::UTF8.GetString($content) + } + $parsed = $null + $responseType = $response.Headers['Content-Type'] -join ',' + if (-not [string]::IsNullOrWhiteSpace($content)) { + if ($responseType -match 'json') { + try { + $parsed = ConvertFrom-Json -InputObject $content -Depth 100 -ErrorAction Stop + } catch [System.ArgumentException] { + throw [System.FormatException]::new("Discord returned invalid JSON (HTTP $status).") + } + } else { + $parsed = $content + } + } + + if ($status -eq 429 -and $attempt -lt $RetryCount) { + $delayValue = $null + if ($null -ne $parsed -and $null -ne $parsed.PSObject.Properties['retry_after']) { + $delayValue = $parsed.retry_after + } elseif ($response.Headers['Retry-After']) { + $delayValue = @($response.Headers['Retry-After'])[0] + } + + $delay = $RetryInterval + if ($null -eq $delayValue) { + Write-Warning 'Discord omitted its rate-limit delay. Using the configured RetryInterval.' + } else { + $text = [System.Convert]::ToString($delayValue, [cultureinfo]::InvariantCulture) + if (-not [double]::TryParse( + $text, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $delay + ) -or -not [double]::IsFinite($delay) -or $delay -lt 0) { + throw [System.InvalidOperationException]::new('Discord returned an invalid rate-limit delay.') + } + } + Write-Verbose 'Waiting for the Discord rate limit before retrying.' + Start-Sleep -Seconds $delay + continue + } + + if ($status -lt 200 -or $status -ge 300) { + $message = "Discord request failed (HTTP $status). Check credentials, scopes, permissions, and request parameters." + throw [System.Net.Http.HttpRequestException]::new($message, $null, [System.Net.HttpStatusCode] $status) + } + + $responseHeaders = [ordered]@{} + foreach ($key in ($response.Headers.Keys | Sort-Object)) { + if ($key -notin @('Authorization', 'Proxy-Authorization', 'Set-Cookie')) { + $responseHeaders[$key] = $response.Headers[$key] -join ', ' + } + } + + [pscustomobject]@{ + Request = [pscustomobject]@{ Method = $Method; Uri = $Uri } + Response = $parsed + Headers = [pscustomobject] $responseHeaders + StatusCode = $status + StatusDescription = [string] $response.StatusDescription + } + return + } + } finally { + $headers.Remove('Authorization') + $plainToken = $null + if ($null -ne $form) { + $form.Clear() + } + } +} diff --git a/src/functions/private/Auth/Context/Assert-DiscordContext.ps1 b/src/functions/private/Auth/Context/Assert-DiscordContext.ps1 new file mode 100644 index 0000000..a7238bf --- /dev/null +++ b/src/functions/private/Auth/Context/Assert-DiscordContext.ps1 @@ -0,0 +1,41 @@ +function Assert-DiscordContext { + <# + .SYNOPSIS + Validate the authentication contract of a Discord context. + + .DESCRIPTION + Ensure the stored type and authentication scheme agree and credentials are available. + + .EXAMPLE + Assert-DiscordContext -Context (Get-DiscordContext) + + .OUTPUTS + None + + Invalid contexts raise a terminating error. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Context/Get-DiscordContext/ + #> + [OutputType([void])] + [CmdletBinding()] + param( + # The resolved connection to validate. + [Parameter(Mandatory)] + [DiscordContext] $Context + ) + + Assert-DiscordContextName -Name $Context.Name + if ($null -eq $Context.Token -or $Context.Token.Length -eq 0) { + throw [System.InvalidOperationException]::new('The context has no access token. Run Connect-Discord again.') + } + if (($Context.Type -eq 'Bot' -and $Context.AuthType -ne 'Bot') -or + ($Context.Type -eq 'User' -and $Context.AuthType -ne 'Bearer') -or + $Context.Type -notin @('Bot', 'User')) { + throw [System.InvalidOperationException]::new('The context authentication type is invalid. Run Connect-Discord again.') + } + if ($Context.Type -eq 'User' -and + ($Context.Scope -cnotcontains 'identify' -or $null -eq $Context.TokenExpiresAt)) { + throw [System.InvalidOperationException]::new('The OAuth2 context needs identify scope and token expiry. Run Connect-Discord again.') + } +} diff --git a/src/functions/private/Auth/Context/Assert-DiscordContextName.ps1 b/src/functions/private/Auth/Context/Assert-DiscordContextName.ps1 new file mode 100644 index 0000000..3cbfdae --- /dev/null +++ b/src/functions/private/Auth/Context/Assert-DiscordContextName.ps1 @@ -0,0 +1,33 @@ +function Assert-DiscordContextName { + <# + .SYNOPSIS + Validate an exact saved-context name. + + .DESCRIPTION + Reject empty names, wildcard selection, and the reserved module settings key. + + .EXAMPLE + Assert-DiscordContextName -Name 'automation' + + .OUTPUTS + None + + Invalid names raise a terminating error. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Context/Get-DiscordContext/ + #> + [OutputType([void])] + [CmdletBinding()] + param( + # The exact vault key, not a wildcard expression. + [Parameter(Mandatory)] + [string] $Name + ) + + if ([string]::IsNullOrWhiteSpace($Name) -or + [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Name) -or + $Name -eq $script:Discord.DefaultConfig.ID) { + throw [System.ArgumentException]::new('Context names must be nonempty, contain no wildcards, and not be Module.') + } +} diff --git a/src/functions/private/Auth/Context/Resolve-DiscordContext.ps1 b/src/functions/private/Auth/Context/Resolve-DiscordContext.ps1 new file mode 100644 index 0000000..b83fd61 --- /dev/null +++ b/src/functions/private/Auth/Context/Resolve-DiscordContext.ps1 @@ -0,0 +1,42 @@ +function Resolve-DiscordContext { + <# + .SYNOPSIS + Resolve a saved name or typed context. + + .DESCRIPTION + Use the selected default when no context is supplied. Resolution does not refresh tokens. + + .EXAMPLE + Resolve-DiscordContext -Context 'automation' + + .OUTPUTS + DiscordContext + + The selected connection. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Context/Get-DiscordContext/ + #> + [OutputType([DiscordContext])] + [CmdletBinding()] + param( + # A saved name or a DiscordContext object. + [Parameter()] + [object] $Context + ) + + if ($null -eq $Context) { + $resolved = Get-DiscordContext + } elseif ($Context -is [string]) { + $resolved = Get-DiscordContext -Context $Context + } elseif ($Context -is [DiscordContext]) { + $resolved = $Context + } else { + throw [System.ArgumentException]::new('Context must be a saved name or a DiscordContext object.') + } + + if ($null -eq $resolved) { + throw [System.InvalidOperationException]::new('No Discord context is selected. Run Connect-Discord or Switch-DiscordContext.') + } + $resolved +} diff --git a/src/functions/private/Auth/Context/Set-DiscordContext.ps1 b/src/functions/private/Auth/Context/Set-DiscordContext.ps1 new file mode 100644 index 0000000..6f18bf7 --- /dev/null +++ b/src/functions/private/Auth/Context/Set-DiscordContext.ps1 @@ -0,0 +1,55 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.3' } + +function Set-DiscordContext { + <# + .SYNOPSIS + Save a verified connection in the Discord vault. + + .DESCRIPTION + Keep the vault key separate from UserID and optionally select the saved connection. + + .EXAMPLE + Set-DiscordContext -Context $connection -Default + + .OUTPUTS + None + + The connection is persisted without emitting credentials. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [OutputType([void])] + [CmdletBinding(SupportsShouldProcess)] + param( + # A connection whose identity has already been verified. + [Parameter(Mandatory)] + [DiscordContext] $Context, + + # Select this saved context as the module default. + [Parameter()] + [switch] $Default + ) + + Assert-DiscordContext -Context $Context + if ($Context.UserID -cnotmatch '^\d{1,20}$' -or [string]::IsNullOrWhiteSpace($Context.UserName)) { + throw [System.InvalidOperationException]::new('A verified Discord user ID and username are required before saving a context.') + } + + if ($PSCmdlet.ShouldProcess($Context.Name, 'Save Discord context')) { + $Context.ID = $Context.Name + $parameters = @{ + ID = $Context.Name + Context = $Context + Vault = $script:Discord.ContextVault + ErrorAction = 'Stop' + Verbose = $false + Debug = $false + Confirm = $false + } + Set-Context @parameters + if ($Default) { + Set-DiscordConfig -DefaultContext $Context.Name -Confirm:$false + } + } +} diff --git a/src/functions/private/Auth/OAuth2/ConvertFrom-DiscordAuthorizationResponse.ps1 b/src/functions/private/Auth/OAuth2/ConvertFrom-DiscordAuthorizationResponse.ps1 new file mode 100644 index 0000000..ea6f2f7 --- /dev/null +++ b/src/functions/private/Auth/OAuth2/ConvertFrom-DiscordAuthorizationResponse.ps1 @@ -0,0 +1,66 @@ +function ConvertFrom-DiscordAuthorizationResponse { + <# + .SYNOPSIS + Validate a Discord authorization callback. + + .DESCRIPTION + Require the original redirect, an exact state match, Discord's issuer, and one authorization code. + + .EXAMPLE + ConvertFrom-DiscordAuthorizationResponse -ResponseUri $callback -RedirectUri $redirect -State $state + + .OUTPUTS + System.Security.SecureString + + The authorization code after all callback validation succeeds. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The OAuth2 callback supplies a plaintext code which must not leave the boundary as plaintext.' + )] + [OutputType([securestring])] + [CmdletBinding()] + param( + # The callback received by the temporary listener. + [Parameter(Mandatory)] + [uri] $ResponseUri, + + # The original registered redirect. + [Parameter(Mandatory)] + [uri] $RedirectUri, + + # The nonce generated for this request. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $State + ) + + if (-not $ResponseUri.IsAbsoluteUri -or $ResponseUri.UserInfo -ne '' -or $ResponseUri.Fragment -ne '' -or + $ResponseUri.GetLeftPart([System.UriPartial]::Path) -cne $RedirectUri.AbsoluteUri) { + throw [System.InvalidOperationException]::new('The authorization response does not match the registered redirect.') + } + + $query = [System.Web.HttpUtility]::ParseQueryString($ResponseUri.Query) + foreach ($name in @('state', 'iss')) { + $values = $query.GetValues($name) + if ($query.AllKeys -cnotcontains $name -or $null -eq $values -or $values.Count -ne 1) { + throw [System.InvalidOperationException]::new('The authorization response has missing or repeated security parameters.') + } + } + if ($query['state'] -cne $State -or $query['iss'] -cne 'https://discord.com') { + throw [System.InvalidOperationException]::new('The authorization response state or issuer is invalid. Sign in again.') + } + if ($null -ne $query.GetValues('error')) { + throw [System.InvalidOperationException]::new('Discord authorization was denied or failed. Sign in again to continue.') + } + + $codes = $query.GetValues('code') + if ($query.AllKeys -cnotcontains 'code' -or $null -eq $codes -or + $codes.Count -ne 1 -or [string]::IsNullOrWhiteSpace($codes[0])) { + throw [System.InvalidOperationException]::new('Discord did not return one valid authorization code.') + } + ConvertTo-SecureString -String $codes[0] -AsPlainText -Force +} diff --git a/src/functions/private/Auth/OAuth2/ConvertTo-DiscordCodeChallenge.ps1 b/src/functions/private/Auth/OAuth2/ConvertTo-DiscordCodeChallenge.ps1 new file mode 100644 index 0000000..dba6bda --- /dev/null +++ b/src/functions/private/Auth/OAuth2/ConvertTo-DiscordCodeChallenge.ps1 @@ -0,0 +1,37 @@ +function ConvertTo-DiscordCodeChallenge { + <# + .SYNOPSIS + Derive an S256 PKCE challenge. + + .DESCRIPTION + Hash the verifier and encode the result with unpadded base64url. + + .EXAMPLE + ConvertTo-DiscordCodeChallenge -CodeVerifier $verifier + + .OUTPUTS + System.String + + The public S256 challenge, not the secret verifier. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The generated PKCE verifier. + [Parameter(Mandatory)] + [securestring] $CodeVerifier + ) + + $bytes = [System.Text.Encoding]::ASCII.GetBytes( + (ConvertFrom-SecureString -SecureString $CodeVerifier -AsPlainText) + ) + try { + $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) + [Convert]::ToBase64String($hash).TrimEnd('=').Replace('+', '-').Replace('/', '_') + } finally { + [Array]::Clear($bytes, 0, $bytes.Length) + } +} diff --git a/src/functions/private/Auth/OAuth2/Invoke-DiscordOAuthToken.ps1 b/src/functions/private/Auth/OAuth2/Invoke-DiscordOAuthToken.ps1 new file mode 100644 index 0000000..c61f110 --- /dev/null +++ b/src/functions/private/Auth/OAuth2/Invoke-DiscordOAuthToken.ps1 @@ -0,0 +1,99 @@ +function Invoke-DiscordOAuthToken { + <# + .SYNOPSIS + Exchange an authorization code or refresh token. + + .DESCRIPTION + Use form encoding and confidential application credentials, then return only secure credential values. + + .EXAMPLE + Invoke-DiscordOAuthToken -ClientID $id -ClientSecret $secret -RefreshToken $refreshToken + + .OUTPUTS + System.Management.Automation.PSCustomObject + + Secure access and refresh tokens, expiry, and granted user scopes. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'OAuth2 token responses are plaintext; credentials are secured before leaving this function.' + )] + [OutputType([pscustomobject])] + [CmdletBinding(DefaultParameterSetName = 'Authorization code')] + param( + # The Discord application ID. + [Parameter(Mandatory)] + [ValidatePattern('^\d{1,20}$')] + [string] $ClientID, + + # The confidential application's client secret. + [Parameter(Mandatory)] + [securestring] $ClientSecret, + + # The validated callback code. + [Parameter(Mandatory, ParameterSetName = 'Authorization code')] + [securestring] $Code, + + # The verifier corresponding to the authorization request's S256 challenge. + [Parameter(Mandatory, ParameterSetName = 'Authorization code')] + [securestring] $CodeVerifier, + + # The exact URI used in the original authorization request. + [Parameter(Mandatory, ParameterSetName = 'Authorization code')] + [uri] $RedirectUri, + + # The stored refresh token for the existing user grant. + [Parameter(Mandatory, ParameterSetName = 'Refresh token')] + [securestring] $RefreshToken + ) + + if ($ClientSecret.Length -eq 0) { + throw [System.ArgumentException]::new('A nonempty OAuth2 client secret is required.') + } + $form = @{ + client_id = $ClientID + client_secret = $ClientSecret + } + if ($PSCmdlet.ParameterSetName -eq 'Authorization code') { + $form.grant_type = 'authorization_code' + $form.code = $Code + $form.code_verifier = $CodeVerifier + $form.redirect_uri = $RedirectUri.AbsoluteUri + } else { + $form.grant_type = 'refresh_token' + $form.refresh_token = $RefreshToken + } + + $parameters = @{ + Uri = [uri] 'https://discord.com/api/oauth2/token' + Method = 'POST' + ContentType = 'application/x-www-form-urlencoded' + Body = $form + } + $result = Invoke-DiscordRequest @parameters + $response = $result.Response + $expiresIn = 0.0 + $expiryText = [System.Convert]::ToString($response.expires_in, [cultureinfo]::InvariantCulture) + if ($null -eq $response -or $response.token_type -ine 'Bearer' -or + [string]::IsNullOrWhiteSpace($response.access_token) -or + [string]::IsNullOrWhiteSpace($response.refresh_token) -or + -not [double]::TryParse( + $expiryText, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $expiresIn + ) -or -not [double]::IsFinite($expiresIn) -or $expiresIn -le 0) { + throw [System.InvalidOperationException]::new('Discord returned an incomplete OAuth2 token response. Sign in again.') + } + $scopes = @($response.scope -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($scopes -cnotcontains 'identify') { + throw [System.InvalidOperationException]::new('Discord did not grant identify scope. Sign in again with identify.') + } + + [pscustomobject]@{ + Token = ConvertTo-SecureString -String $response.access_token -AsPlainText -Force + RefreshToken = ConvertTo-SecureString -String $response.refresh_token -AsPlainText -Force + TokenExpiresAt = [datetime]::UtcNow.AddSeconds($expiresIn) + Scope = [string[]] $scopes + } +} diff --git a/src/functions/private/Auth/OAuth2/New-DiscordAuthorization.ps1 b/src/functions/private/Auth/OAuth2/New-DiscordAuthorization.ps1 new file mode 100644 index 0000000..454531a --- /dev/null +++ b/src/functions/private/Auth/OAuth2/New-DiscordAuthorization.ps1 @@ -0,0 +1,84 @@ +function New-DiscordAuthorization { + <# + .SYNOPSIS + Prepare a scoped authorization-code request. + + .DESCRIPTION + Validate application configuration and create independent random state and S256 PKCE values. + + .EXAMPLE + New-DiscordAuthorization -ClientID $clientID -RedirectUri 'http://localhost:53682/' -Scope identify + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The authorization URI, callback URI, state, and secure verifier for this sign-in only. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The cryptographically generated verifier enters the credential boundary as a SecureString.' + )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Creates only in-memory request values; opening a browser and saving context are guarded by the caller.' + )] + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + # The configured Discord application ID. + [Parameter(Mandatory)] + [ValidatePattern('^\d{1,20}$')] + [string] $ClientID, + + # The exact registered loopback URI. + [Parameter(Mandatory)] + [uri] $RedirectUri, + + # The requested user scopes, including identify. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] $Scope + ) + + if (-not $RedirectUri.IsAbsoluteUri -or $RedirectUri.Scheme -cne 'http' -or + $RedirectUri.DnsSafeHost -notin @('localhost', '127.0.0.1') -or + $RedirectUri.Port -lt 1024 -or $RedirectUri.UserInfo -ne '' -or + $RedirectUri.Query -ne '' -or $RedirectUri.Fragment -ne '' -or + -not $RedirectUri.AbsolutePath.EndsWith('/', [System.StringComparison]::Ordinal)) { + throw [System.ArgumentException]::new( + 'RedirectUri must be a registered HTTP localhost or 127.0.0.1 URI with a nonprivileged port and a trailing slash.' + ) + } + if ($Scope -cnotcontains 'identify' -or + @($Scope | Where-Object { $_ -cnotmatch '\A[a-z0-9._-]+\z' }).Count -gt 0 -or + @($Scope | Where-Object { $_ -in @('bot', 'webhook.incoming', 'applications.commands') }).Count -gt 0) { + throw [System.ArgumentException]::new('User sign-in requires identify scope and does not accept installation scopes.') + } + + $state = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) + $random = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) + $verifierText = [Convert]::ToBase64String($random).TrimEnd('=').Replace('+', '-').Replace('/', '_') + $verifier = ConvertTo-SecureString -String $verifierText -AsPlainText -Force + $verifierText = $null + [Array]::Clear($random, 0, $random.Length) + $query = @{ + client_id = $ClientID + response_type = 'code' + redirect_uri = $RedirectUri.AbsoluteUri + scope = ($Scope | Select-Object -Unique) -join ' ' + state = $state + code_challenge = ConvertTo-DiscordCodeChallenge -CodeVerifier $verifier + code_challenge_method = 'S256' + prompt = 'consent' + } + + [pscustomobject]@{ + Uri = [uri] ('https://discord.com/api/oauth2/authorize?' + (ConvertTo-DiscordQueryString -Query $query)) + RedirectUri = $RedirectUri + State = $state + CodeVerifier = $verifier + } +} diff --git a/src/functions/private/Auth/OAuth2/Update-DiscordAccessToken.ps1 b/src/functions/private/Auth/OAuth2/Update-DiscordAccessToken.ps1 new file mode 100644 index 0000000..fe358ff --- /dev/null +++ b/src/functions/private/Auth/OAuth2/Update-DiscordAccessToken.ps1 @@ -0,0 +1,55 @@ +function Update-DiscordAccessToken { + <# + .SYNOPSIS + Refresh an expiring OAuth2 connection. + + .DESCRIPTION + Refresh at most 30 seconds before expiry and persist the rotated credentials before the API request continues. + + .EXAMPLE + Update-DiscordAccessToken -Context $connection + + .OUTPUTS + None + + The supplied connection and its saved credentials are updated when needed. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [OutputType([void])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The already resolved user connection. + [Parameter(Mandatory)] + [DiscordContext] $Context + ) + + if ($Context.Type -ne 'User') { + return + } + if ($null -ne $Context.TokenExpiresAt -and + ([datetime] $Context.TokenExpiresAt).ToUniversalTime() -gt [datetime]::UtcNow.AddSeconds(30)) { + return + } + if ([string]::IsNullOrWhiteSpace($Context.ClientID) -or + $null -eq $Context.ClientSecret -or $Context.ClientSecret.Length -eq 0 -or + $null -eq $Context.RefreshToken -or $Context.RefreshToken.Length -eq 0) { + throw [System.InvalidOperationException]::new('The OAuth2 context cannot refresh its credentials. Run Connect-Discord again.') + } + if (-not $PSCmdlet.ShouldProcess($Context.Name, 'Refresh and save OAuth2 credentials')) { + throw [System.InvalidOperationException]::new('OAuth2 refresh was not approved; no API request was sent.') + } + + $parameters = @{ + ClientID = $Context.ClientID + ClientSecret = $Context.ClientSecret + RefreshToken = $Context.RefreshToken + } + $fresh = Invoke-DiscordOAuthToken @parameters + $Context.Token = $fresh.Token + $Context.RefreshToken = $fresh.RefreshToken + $Context.TokenExpiresAt = $fresh.TokenExpiresAt + $Context.Scope = $fresh.Scope + Set-DiscordContext -Context $Context -Confirm:$false +} diff --git a/src/functions/private/Auth/OAuth2/Wait-DiscordAuthorization.ps1 b/src/functions/private/Auth/OAuth2/Wait-DiscordAuthorization.ps1 new file mode 100644 index 0000000..90ee685 --- /dev/null +++ b/src/functions/private/Auth/OAuth2/Wait-DiscordAuthorization.ps1 @@ -0,0 +1,87 @@ +function Wait-DiscordAuthorization { + <# + .SYNOPSIS + Receive one interactive authorization response. + + .DESCRIPTION + Start the loopback listener before opening a browser, validate the response, and always close the listener. + + .EXAMPLE + Wait-DiscordAuthorization -Authorization $request -TimeoutSeconds 180 + + .OUTPUTS + System.Security.SecureString + + The validated authorization code. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [OutputType([securestring])] + [CmdletBinding()] + param( + # The prepared request containing URI, redirect, and expected state. + [Parameter(Mandatory)] + [pscustomobject] $Authorization, + + # The maximum wait for browser consent. + [Parameter()] + [ValidateRange(1, 900)] + [int] $TimeoutSeconds = 180, + + # Display the URL without starting a browser. + [Parameter()] + [switch] $NoBrowser + ) + + $listener = [System.Net.HttpListener]::new() + try { + $listener.Prefixes.Add($Authorization.RedirectUri.AbsoluteUri) + try { + $listener.Start() + } catch [System.Net.HttpListenerException] { + $failure = $_ + throw [System.InvalidOperationException]::new( + 'Cannot start the OAuth2 loopback listener. Check port availability and OS permissions, or register another redirect port.', + $failure.Exception + ) + } + $information = @{ + MessageData = "Authorize Discord access in a browser on this machine: $($Authorization.Uri.AbsoluteUri)" + InformationAction = 'Continue' + } + Write-Information @information + if (-not $NoBrowser) { + Start-Process -FilePath $Authorization.Uri.AbsoluteUri -ErrorAction Stop + } + + $pending = $listener.GetContextAsync() + if (-not $pending.Wait([timespan]::FromSeconds($TimeoutSeconds))) { + throw [System.TimeoutException]::new('Discord sign-in timed out. Run Connect-Discord again.') + } + $callback = $pending.GetAwaiter().GetResult() + try { + if ($callback.Request.HttpMethod -cne 'GET') { + throw [System.InvalidOperationException]::new('The OAuth2 callback must be a GET request.') + } + $callbackParameters = @{ + ResponseUri = $callback.Request.Url + RedirectUri = $Authorization.RedirectUri + State = $Authorization.State + } + $code = ConvertFrom-DiscordAuthorizationResponse @callbackParameters + $message = [System.Text.Encoding]::UTF8.GetBytes('Discord authorization received. You can close this window.') + $callback.Response.ContentType = 'text/plain; charset=utf-8' + $callback.Response.ContentLength64 = $message.Length + $callback.Response.OutputStream.Write($message, 0, $message.Length) + $code + } catch [System.InvalidOperationException] { + $callback.Response.StatusCode = 400 + throw + } finally { + $callback.Response.Close() + } + } finally { + $listener.Close() + } +} diff --git a/src/functions/private/Config/Initialize-DiscordConfig.ps1 b/src/functions/private/Config/Initialize-DiscordConfig.ps1 new file mode 100644 index 0000000..88237e2 --- /dev/null +++ b/src/functions/private/Config/Initialize-DiscordConfig.ps1 @@ -0,0 +1,44 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.3' } + +function Initialize-DiscordConfig { + <# + .SYNOPSIS + Load saved client settings on first use. + + .DESCRIPTION + Use in-memory defaults when no settings exist. Reads never create a vault or save defaults. + + .EXAMPLE + Initialize-DiscordConfig + + .OUTPUTS + None + + Settings are cached in module scope. + + .LINK + https://psmodule.io/Discord/Functions/Config/Get-DiscordConfig/ + #> + [OutputType([void])] + [CmdletBinding()] + param() + + if ($null -ne $script:Discord.Config) { + return + } + + $parameters = @{ + ID = $script:Discord.DefaultConfig.ID + Vault = $script:Discord.ContextVault + ErrorAction = 'Stop' + WarningAction = 'Stop' + Verbose = $false + Debug = $false + } + $stored = Get-Context @parameters + if ($null -eq $stored) { + $script:Discord.Config = [DiscordConfig]::new([pscustomobject] $script:Discord.DefaultConfig) + } else { + $script:Discord.Config = [DiscordConfig]::new([pscustomobject] $stored) + } +} diff --git a/src/functions/public/API/Invoke-DiscordAPI.ps1 b/src/functions/public/API/Invoke-DiscordAPI.ps1 new file mode 100644 index 0000000..a2820e8 --- /dev/null +++ b/src/functions/public/API/Invoke-DiscordAPI.ps1 @@ -0,0 +1,142 @@ +function Invoke-DiscordAPI { + <# + .SYNOPSIS + Call the Discord REST API with a saved connection. + + .DESCRIPTION + Resolve bot or scoped user credentials, refresh expiring OAuth2 tokens, and return a response envelope. + Only official HTTPS v10 API destinations are accepted. Mutating requests support WhatIf. + + .EXAMPLE + ```powershell + Invoke-DiscordAPI -ApiEndpoint '/users/@me' + ``` + + .EXAMPLE + ```powershell + Invoke-DiscordAPI -ApiEndpoint '/users/@me/guilds' -Query @{ limit = 10 } -Context 'personal' + ``` + + .EXAMPLE + ```powershell + Invoke-DiscordAPI -Uri 'https://discord.com/api/v10/users/@me' -Context (Get-DiscordContext) + ``` + + .INPUTS + None + + You can't pipe objects to Invoke-DiscordAPI. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + Request, Response, Headers, StatusCode, and StatusDescription, without request headers or bodies. + + .LINK + https://psmodule.io/Discord/Functions/API/Invoke-DiscordAPI/ + #> + [OutputType([pscustomobject])] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'API endpoint')] + param( + # The relative API endpoint, including any existing query string. + [Parameter(Mandatory, ParameterSetName = 'API endpoint')] + [ValidateNotNullOrEmpty()] + [string] $ApiEndpoint, + + # An absolute official Discord API v10 URI. + [Parameter(Mandatory, ParameterSetName = 'Full URI')] + [uri] $Uri, + + # GET by default; only HTTP 429 responses are retried. + [Parameter()] + [ValidateSet('GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS')] + [string] $Method = 'GET', + + # A query dictionary for GET/HEAD, or the body for other methods. + [Parameter()] + [AllowNull()] + [Alias('Query')] + [object] $Body, + + # JSON by default; use application/x-www-form-urlencoded for form data. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $ContentType = 'application/json', + + # Override the configured number of additional HTTP 429 attempts. + [Parameter()] + [ValidateRange(0, 10)] + [System.Nullable[int]] $RetryCount, + + # Override the configured fallback rate-limit delay. + [Parameter()] + [ValidateRange(0, 86400)] + [System.Nullable[double]] $RetryInterval, + + # A saved name or DiscordContext object; omit to use the default. + [Parameter()] + [object] $Context + ) + + if ($PSCmdlet.ParameterSetName -eq 'API endpoint') { + if ($ApiEndpoint -match '^(?:[a-z][a-z0-9+.-]*:|//|\\)' -or $ApiEndpoint.Contains('#')) { + throw [System.ArgumentException]::new('ApiEndpoint must be a relative Discord API path without a fragment.') + } + $Uri = [uri] ('https://discord.com/api/v10/' + $ApiEndpoint.TrimStart('/')) + } + Assert-DiscordApiUri -Uri $Uri + + if ($Method -in @('GET', 'HEAD') -and $PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { + if ($Body -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new('GET and HEAD query bodies must be dictionaries.') + } + $query = ConvertTo-DiscordQueryString -Query $Body + if ($query) { + $builder = [System.UriBuilder]::new($Uri) + $existing = $builder.Query.TrimStart('?') + if ($existing) { + $builder.Query = "$existing&$query" + } else { + $builder.Query = $query + } + $Uri = $builder.Uri + } + } + + if ($Method -notin @('GET', 'HEAD', 'OPTIONS') -and + -not $PSCmdlet.ShouldProcess($Uri.GetLeftPart([System.UriPartial]::Path), "$Method Discord API request")) { + return + } + + $resolved = Resolve-DiscordContext -Context $Context + Assert-DiscordContext -Context $resolved + if ($resolved.Type -eq 'User') { + Update-DiscordAccessToken -Context $resolved -Confirm:$false + } + Initialize-DiscordConfig + if ($null -eq $RetryCount) { + $RetryCount = $script:Discord.Config.RetryCount + } + if ($null -eq $RetryInterval) { + $RetryInterval = $script:Discord.Config.RetryInterval + } + + $parameters = @{ + Uri = $Uri + Method = $Method + AuthType = $resolved.AuthType + Token = $resolved.Token + ContentType = $ContentType + RetryCount = $RetryCount + RetryInterval = $RetryInterval + TimeoutSeconds = $script:Discord.Config.TimeoutSeconds + } + if ($Method -notin @('GET', 'HEAD') -and $PSBoundParameters.ContainsKey('Body')) { + if ($ContentType -match '^application/(?:[^;]+\+)?json(?:;|$)' -and $Body -isnot [string]) { + $parameters.Body = ConvertTo-Json -InputObject $Body -Depth 100 -Compress + } else { + $parameters.Body = $Body + } + } + Invoke-DiscordRequest @parameters +} diff --git a/src/functions/public/Auth/Connect-DiscordAccount.ps1 b/src/functions/public/Auth/Connect-DiscordAccount.ps1 new file mode 100644 index 0000000..6b755aa --- /dev/null +++ b/src/functions/public/Auth/Connect-DiscordAccount.ps1 @@ -0,0 +1,172 @@ +function Connect-DiscordAccount { + <# + .SYNOPSIS + Connect to Discord as a bot or OAuth2 user. + + .DESCRIPTION + Verify the authenticated identity before saving a secure Context connection. + User sign-in uses a registered loopback redirect, state and issuer validation, and S256 PKCE with a client secret. + Normal Discord account session tokens and self-bot authentication are not supported. + + .EXAMPLE + ```powershell + $token = Read-Host -Prompt 'Bot token' -AsSecureString + Connect-DiscordAccount -BotToken $token + ``` + + .EXAMPLE + ```powershell + $secret = Read-Host -Prompt 'OAuth2 client secret' -AsSecureString + $connection = @{ + ClientID = 'YOUR_APPLICATION_ID' + ClientSecret = $secret + RedirectUri = 'http://localhost:53682/' + } + Connect-DiscordAccount @connection -Name 'personal' + ``` + + .EXAMPLE + ```powershell + Connect-DiscordAccount -BotToken $token -Name 'automation' -NotDefault -PassThru + ``` + + .INPUTS + None + + You can't pipe objects to Connect-DiscordAccount. + + .OUTPUTS + DiscordContext + + The verified saved connection, only when PassThru is specified. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Connect-DiscordAccount/ + #> + [Alias('Connect-Discord')] + [OutputType([DiscordContext])] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Interactive user')] + param( + # The raw bot token, not a normal account token or an OAuth2 client secret. + [Parameter(Mandatory, ParameterSetName = 'Bot token')] + [securestring] $BotToken, + + # The application ID from the Discord Developer Portal. + [Parameter(Mandatory, ParameterSetName = 'Interactive user')] + [ValidatePattern('^\d{1,20}$')] + [string] $ClientID, + + # The application's OAuth2 client secret. + [Parameter(Mandatory, ParameterSetName = 'Interactive user')] + [securestring] $ClientSecret, + + # The exact registered HTTP loopback URI, with a trailing slash. + [Parameter(ParameterSetName = 'Interactive user')] + [uri] $RedirectUri = 'http://localhost:53682/', + + # User scopes; identify is required and installation scopes are not accepted. + [Parameter(ParameterSetName = 'Interactive user')] + [ValidateNotNullOrEmpty()] + [string[]] $Scope = @('identify'), + + # Display the authorization URL without starting a browser. + [Parameter(ParameterSetName = 'Interactive user')] + [switch] $NoBrowser, + + # The time to wait for browser consent, independent of HTTP request timeouts. + [Parameter(ParameterSetName = 'Interactive user')] + [ValidateRange(1, 900)] + [int] $TimeoutSeconds = 180, + + # An exact saved name; omit for a stable type, user ID, and application-based name. + [Parameter()] + [ValidateNotNullOrEmpty()] + [Alias('ContextName')] + [string] $Name, + + # Save without selecting this connection as the default. + [Parameter()] + [switch] $NotDefault, + + # Return the verified saved connection. + [Parameter()] + [switch] $PassThru + ) + + if ($PSBoundParameters.ContainsKey('Name')) { + Assert-DiscordContextName -Name $Name + } + $context = [DiscordContext]::new() + $context.ID = 'Pending' + $context.Name = 'Pending' + if ($PSCmdlet.ParameterSetName -eq 'Bot token') { + if ($BotToken.Length -eq 0) { + throw [System.ArgumentException]::new('A nonempty bot token is required.') + } + if (-not $PSCmdlet.ShouldProcess('Discord bot connection', 'Authenticate and save context')) { + return + } + $context.Type = 'Bot' + $context.AuthType = 'Bot' + $context.Token = $BotToken + } else { + if ($ClientSecret.Length -eq 0) { + throw [System.ArgumentException]::new('A nonempty OAuth2 client secret is required.') + } + $authorization = New-DiscordAuthorization -ClientID $ClientID -RedirectUri $RedirectUri -Scope $Scope + $code = $null + try { + if (-not $PSCmdlet.ShouldProcess('Discord OAuth2 user connection', 'Authorize and save context')) { + return + } + $code = Wait-DiscordAuthorization -Authorization $authorization -TimeoutSeconds $TimeoutSeconds -NoBrowser:$NoBrowser + $parameters = @{ + ClientID = $ClientID + ClientSecret = $ClientSecret + Code = $code + CodeVerifier = $authorization.CodeVerifier + RedirectUri = $RedirectUri + } + $credentials = Invoke-DiscordOAuthToken @parameters + } finally { + $authorization.CodeVerifier.Dispose() + if ($null -ne $code) { + $code.Dispose() + } + } + $context.Type = 'User' + $context.AuthType = 'Bearer' + $context.ClientID = $ClientID + $context.ClientSecret = $ClientSecret + $context.RedirectUri = $RedirectUri + $context.Token = $credentials.Token + $context.RefreshToken = $credentials.RefreshToken + $context.TokenExpiresAt = $credentials.TokenExpiresAt + $context.Scope = $credentials.Scope + } + + $viewer = Get-DiscordViewer -Context $context + if (($context.Type -eq 'Bot' -and $viewer.bot -ne $true) -or + ($context.Type -eq 'User' -and $viewer.bot -eq $true)) { + throw [System.InvalidOperationException]::new('The authenticated identity does not match the selected bot or user authentication mode.') + } + $context.UserID = [string] $viewer.id + $context.UserName = $viewer.username + $context.DisplayName = $viewer.global_name + if ([string]::IsNullOrWhiteSpace($context.DisplayName)) { + $context.DisplayName = $context.UserName + } + + if ($Name) { + $context.Name = $Name + } elseif ($context.Type -eq 'Bot') { + $context.Name = "Bot-$($context.UserID)" + } else { + $context.Name = "User-$($context.UserID)-$ClientID" + } + $context.ID = $context.Name + Set-DiscordContext -Context $context -Default:(-not $NotDefault) -Confirm:$false + if ($PassThru) { + Get-DiscordContext -Context $context.Name + } +} diff --git a/src/functions/public/Auth/Context/Get-DiscordContext.ps1 b/src/functions/public/Auth/Context/Get-DiscordContext.ps1 new file mode 100644 index 0000000..471e2a2 --- /dev/null +++ b/src/functions/public/Auth/Context/Get-DiscordContext.ps1 @@ -0,0 +1,91 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.3' } + +function Get-DiscordContext { + <# + .SYNOPSIS + Get a saved Discord connection. + + .DESCRIPTION + Restore the default, one named context, or all saved contexts from the encrypted Context vault. + + .EXAMPLE + ```powershell + Get-DiscordContext + ``` + + .EXAMPLE + ```powershell + Get-DiscordContext -Context 'automation' + ``` + + .EXAMPLE + ```powershell + Get-DiscordContext -ListAvailable + ``` + + .INPUTS + None + + You can't pipe objects to Get-DiscordContext. + + .OUTPUTS + DiscordContext + + Typed saved connections, sorted by name. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Context/Get-DiscordContext/ + #> + [OutputType([DiscordContext])] + [CmdletBinding(DefaultParameterSetName = 'Get default context')] + param( + # The exact saved name. + [Parameter(Mandatory, ParameterSetName = 'Get named context')] + [ValidateNotNullOrEmpty()] + [Alias('Name')] + [string] $Context, + + # Return all saved connections, excluding module settings. + [Parameter(Mandatory, ParameterSetName = 'List available contexts')] + [switch] $ListAvailable + ) + + if ($PSBoundParameters.ContainsKey('Context')) { + Assert-DiscordContextName -Name $Context + } + Initialize-DiscordConfig + if ($ListAvailable) { + $id = '*' + } elseif ($PSBoundParameters.ContainsKey('Context')) { + $id = $Context + } else { + $id = $script:Discord.Config.DefaultContext + if ([string]::IsNullOrWhiteSpace($id)) { + Write-Warning 'No default Discord context is selected. Run Connect-Discord or Switch-DiscordContext.' + return + } + Assert-DiscordContextName -Name $id + } + + $parameters = @{ + ID = $id + Vault = $script:Discord.ContextVault + ErrorAction = 'Stop' + WarningAction = 'Stop' + Verbose = $false + Debug = $false + } + $items = @(Get-Context @parameters | Where-Object { $_.ID -ne $script:Discord.DefaultConfig.ID }) + if (-not $ListAvailable -and $items.Count -eq 0) { + throw [System.InvalidOperationException]::new("Discord context '$id' was not found. Run Connect-Discord to create it.") + } + + foreach ($item in ($items | Sort-Object -Property Name)) { + $restored = [DiscordContext]::new([pscustomobject] $item) + Assert-DiscordContextName -Name $restored.Name + if ($restored.ID -ne $restored.Name) { + throw [System.InvalidOperationException]::new('A saved Discord context has an inconsistent ID. Run Connect-Discord again.') + } + $restored + } +} diff --git a/src/functions/public/Auth/Context/Switch-DiscordContext.ps1 b/src/functions/public/Auth/Context/Switch-DiscordContext.ps1 new file mode 100644 index 0000000..1e94666 --- /dev/null +++ b/src/functions/public/Auth/Context/Switch-DiscordContext.ps1 @@ -0,0 +1,50 @@ +function Switch-DiscordContext { + <# + .SYNOPSIS + Select a saved Discord connection. + + .DESCRIPTION + Persist the default selection without authenticating or refreshing the connection. + + .EXAMPLE + ```powershell + Switch-DiscordContext -Context 'personal' + ``` + + .INPUTS + System.String + + The name of a saved connection. + + .OUTPUTS + DiscordContext + + The selected connection, only when PassThru is specified. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Context/Switch-DiscordContext/ + #> + [OutputType([DiscordContext])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The exact saved name, or the Name property of a piped context. + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [Alias('Name')] + [string] $Context, + + # Return the selected connection. + [Parameter()] + [switch] $PassThru + ) + + process { + $resolved = Get-DiscordContext -Context $Context + if ($PSCmdlet.ShouldProcess($resolved.Name, 'Select default Discord context')) { + Set-DiscordConfig -DefaultContext $resolved.Name -Confirm:$false + if ($PassThru) { + $resolved + } + } + } +} diff --git a/src/functions/public/Auth/Disconnect-DiscordAccount.ps1 b/src/functions/public/Auth/Disconnect-DiscordAccount.ps1 new file mode 100644 index 0000000..c9d5d48 --- /dev/null +++ b/src/functions/public/Auth/Disconnect-DiscordAccount.ps1 @@ -0,0 +1,69 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.3' } + +function Disconnect-DiscordAccount { + <# + .SYNOPSIS + Remove a local saved Discord connection. + + .DESCRIPTION + Delete one context and clear the default if it selected that context. + This does not refresh or remotely revoke tokens. + + .EXAMPLE + ```powershell + Disconnect-DiscordAccount + ``` + + .EXAMPLE + ```powershell + Get-DiscordContext -Context 'personal' | Disconnect-DiscordAccount + ``` + + .INPUTS + System.String + + A saved context name. + + .INPUTS + DiscordContext + + A saved connection. + + .OUTPUTS + None + + The local connection is removed without returning credentials. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Disconnect-DiscordAccount/ + #> + [Alias('Disconnect-Discord')] + [OutputType([void])] + [CmdletBinding(SupportsShouldProcess)] + param( + # A saved name or typed connection; omit to remove the default. + [Parameter(ValueFromPipeline)] + [Alias('Name')] + [object] $Context + ) + + process { + $resolved = Resolve-DiscordContext -Context $Context + Assert-DiscordContextName -Name $resolved.Name + if ($PSCmdlet.ShouldProcess($resolved.Name, 'Remove local Discord context')) { + $parameters = @{ + ID = $resolved.Name + Vault = $script:Discord.ContextVault + ErrorAction = 'Stop' + Verbose = $false + Debug = $false + Confirm = $false + } + Remove-Context @parameters + Initialize-DiscordConfig + if ($script:Discord.Config.DefaultContext -eq $resolved.Name) { + Set-DiscordConfig -DefaultContext '' -Confirm:$false + } + } + } +} diff --git a/src/functions/public/Auth/Get-DiscordViewer.ps1 b/src/functions/public/Auth/Get-DiscordViewer.ps1 new file mode 100644 index 0000000..45ae21b --- /dev/null +++ b/src/functions/public/Auth/Get-DiscordViewer.ps1 @@ -0,0 +1,51 @@ +function Get-DiscordViewer { + <# + .SYNOPSIS + Get the authenticated Discord user or bot. + + .DESCRIPTION + Return GET /users/@me using the selected connection. OAuth2 user access requires identify scope. + + .EXAMPLE + ```powershell + Get-DiscordViewer + ``` + + .EXAMPLE + ```powershell + Get-DiscordViewer -Context 'automation' + ``` + + .INPUTS + None + + You can't pipe objects to Get-DiscordViewer. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The current Discord user object, with its snowflake ID preserved as a string. + + .LINK + https://psmodule.io/Discord/Functions/Auth/Get-DiscordViewer/ + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + # A saved connection name or a DiscordContext object. + [Parameter()] + [object] $Context + ) + + $resolved = Resolve-DiscordContext -Context $Context + $result = Invoke-DiscordAPI -ApiEndpoint '/users/@me' -Context $resolved + $viewer = $result.Response + if ($null -eq $viewer -or $viewer.id -cnotmatch '^\d{1,20}$' -or + [string]::IsNullOrWhiteSpace($viewer.username)) { + throw [System.InvalidOperationException]::new('Discord returned an invalid current-user response.') + } + if ($resolved.UserID -and $resolved.UserID -cne [string] $viewer.id) { + throw [System.InvalidOperationException]::new('The authenticated identity differs from the saved context. Run Connect-Discord again.') + } + $viewer +} diff --git a/src/functions/public/Config/Get-DiscordConfig.ps1 b/src/functions/public/Config/Get-DiscordConfig.ps1 new file mode 100644 index 0000000..1ced35c --- /dev/null +++ b/src/functions/public/Config/Get-DiscordConfig.ps1 @@ -0,0 +1,33 @@ +function Get-DiscordConfig { + <# + .SYNOPSIS + Get the Discord client settings. + + .DESCRIPTION + Return a copy of the saved settings or defaults without creating a vault. + + .EXAMPLE + ```powershell + Get-DiscordConfig + ``` + + .INPUTS + None + + You can't pipe objects to Get-DiscordConfig. + + .OUTPUTS + DiscordConfig + + The default context name, retry settings, and HTTP timeout. + + .LINK + https://psmodule.io/Discord/Functions/Config/Get-DiscordConfig/ + #> + [OutputType([DiscordConfig])] + [CmdletBinding()] + param() + + Initialize-DiscordConfig + [DiscordConfig]::new([pscustomobject] $script:Discord.Config) +} diff --git a/src/functions/public/Config/Set-DiscordConfig.ps1 b/src/functions/public/Config/Set-DiscordConfig.ps1 new file mode 100644 index 0000000..8f49643 --- /dev/null +++ b/src/functions/public/Config/Set-DiscordConfig.ps1 @@ -0,0 +1,90 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.3' } + +function Set-DiscordConfig { + <# + .SYNOPSIS + Save Discord client settings. + + .DESCRIPTION + Change only specified settings. A nonempty default must identify an existing saved context. + + .EXAMPLE + ```powershell + Set-DiscordConfig -RetryCount 3 -RetryInterval 1 -TimeoutSeconds 100 + ``` + + .EXAMPLE + ```powershell + Set-DiscordConfig -DefaultContext 'automation' -PassThru + ``` + + .INPUTS + None + + You can't pipe objects to Set-DiscordConfig. + + .OUTPUTS + DiscordConfig + + The saved settings, only when PassThru is specified. + + .LINK + https://psmodule.io/Discord/Functions/Config/Set-DiscordConfig/ + #> + [OutputType([DiscordConfig])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The saved default name, or an empty string to clear the selection. + [Parameter()] + [AllowEmptyString()] + [string] $DefaultContext, + + # The number of additional attempts after an HTTP 429 response. + [Parameter()] + [ValidateRange(0, 10)] + [int] $RetryCount, + + # The fallback delay when a 429 response omits its retry delay. + [Parameter()] + [ValidateRange(0, 86400)] + [double] $RetryInterval, + + # The timeout for each HTTP request, in seconds. + [Parameter()] + [ValidateRange(1, 3600)] + [int] $TimeoutSeconds, + + # Return the saved configuration. + [Parameter()] + [switch] $PassThru + ) + + Initialize-DiscordConfig + if ($PSBoundParameters.ContainsKey('DefaultContext') -and $DefaultContext -ne '') { + $null = Get-DiscordContext -Context $DefaultContext + } + + $config = [DiscordConfig]::new([pscustomobject] $script:Discord.Config) + foreach ($name in @('DefaultContext', 'RetryCount', 'RetryInterval', 'TimeoutSeconds')) { + if ($PSBoundParameters.ContainsKey($name)) { + $config.$name = $PSBoundParameters[$name] + } + } + + if ($PSCmdlet.ShouldProcess('Discord module settings', 'Save configuration')) { + $parameters = @{ + ID = $config.ID + Context = $config + Vault = $script:Discord.ContextVault + ErrorAction = 'Stop' + Verbose = $false + Debug = $false + Confirm = $false + } + Set-Context @parameters + $script:Discord.Config = $config + if ($PassThru) { + Get-DiscordConfig + } + } +} diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 deleted file mode 100644 index 77483d0..0000000 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -function Get-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/header.ps1 b/src/header.ps1 new file mode 100644 index 0000000..57c95e4 --- /dev/null +++ b/src/header.ps1 @@ -0,0 +1,4 @@ +<# + .SYNOPSIS + A PowerShell client for Discord bot and scoped OAuth2 user authentication. +#> diff --git a/src/init/Initialize-Discord.ps1 b/src/init/Initialize-Discord.ps1 new file mode 100644 index 0000000..50da6f7 --- /dev/null +++ b/src/init/Initialize-Discord.ps1 @@ -0,0 +1,4 @@ +#Requires -Version 7.4 + +$ErrorActionPreference = 'Stop' +Write-Verbose 'Discord client loaded. Saved contexts are resolved on demand.' diff --git a/src/manifest.psd1 b/src/manifest.psd1 new file mode 100644 index 0000000..a1eefbe --- /dev/null +++ b/src/manifest.psd1 @@ -0,0 +1,12 @@ +@{ + PrivateData = @{ + PSData = @{ + Tags = @( + 'Discord' + 'OAuth2' + 'Bot' + 'PSModule' + ) + } + } +} diff --git a/src/variables/private/Discord.ps1 b/src/variables/private/Discord.ps1 new file mode 100644 index 0000000..f89494a --- /dev/null +++ b/src/variables/private/Discord.ps1 @@ -0,0 +1,7 @@ +$script:Discord = [pscustomobject]@{ + ContextVault = 'PSModule.Discord' + DefaultConfig = [DiscordConfig]::new() + Config = $null +} + +$script:UserAgent = 'DiscordBot (https://github.com/PSModule/Discord, {0})' -f $ExecutionContext.SessionState.Module.Version diff --git a/tests/Auth.Tests.ps1 b/tests/Auth.Tests.ps1 new file mode 100644 index 0000000..8e67ae5 --- /dev/null +++ b/tests/Auth.Tests.ps1 @@ -0,0 +1,228 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The framework injects the CI secret through TestData; it is secured immediately without logging.' +)] +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +Describe 'Live Discord client foundation' -Tag 'Integration' { + BeforeAll { + $rawToken = [Environment]::GetEnvironmentVariable('DISCORD_BOT_TOKEN') + if ([string]::IsNullOrWhiteSpace($rawToken)) { + throw 'Set the DISCORD_BOT_TOKEN repository secret and pass it through workflow TestData to run the live tests.' + } + $token = ConvertTo-SecureString -String $rawToken -AsPlainText -Force + $rawToken = $null + $runID = [Environment]::GetEnvironmentVariable('GITHUB_RUN_ID') + if ([string]::IsNullOrWhiteSpace($runID)) { + $runID = "Local-$PID" + } + $runnerOS = [Environment]::GetEnvironmentVariable('RUNNER_OS') + if ([string]::IsNullOrWhiteSpace($runnerOS)) { + $runnerOS = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription -replace '[^a-zA-Z0-9]', '' + } + $contextName = "DiscordClient-$runnerOS-$runID" + $secondContextName = "$contextName-Second" + $originalConfig = Get-DiscordConfig + $context = Connect-DiscordAccount -BotToken $token -Name $contextName -NotDefault -PassThru + } + + AfterAll { + if ($contextName) { + foreach ($saved in @(Get-DiscordContext -ListAvailable)) { + if ($saved.Name -in @($contextName, $secondContextName)) { + Disconnect-DiscordAccount -Context $saved.Name -Confirm:$false + } + } + } + if ($null -ne $originalConfig) { + $restore = @{ + DefaultContext = $originalConfig.DefaultContext + RetryCount = $originalConfig.RetryCount + RetryInterval = $originalConfig.RetryInterval + TimeoutSeconds = $originalConfig.TimeoutSeconds + Confirm = $false + } + Set-DiscordConfig @restore + } + if ($null -ne $token) { + $token.Dispose() + } + } + + It 'Authenticates a real bot and stores its verified identity securely' { + $context.GetType().Name | Should-Be -Expected 'DiscordContext' + $context.Name | Should-Be -Expected $contextName + $context.Type | Should-Be -Expected 'Bot' + $context.AuthType | Should-Be -Expected 'Bot' + ($context.Token -is [securestring]) | Should-Be -Expected $true + ($context.UserID -is [string]) | Should-Be -Expected $true + ($context.UserID -cmatch '^\d{1,20}$') | Should-Be -Expected $true + ([string]::IsNullOrWhiteSpace($context.UserName)) | Should-Be -Expected $false + } + + It 'Returns the same authenticated identity through the public identity command' { + $viewer = Get-DiscordViewer -Context $contextName + + $viewer.id | Should-Be -Expected $context.UserID + $viewer.username | Should-Be -Expected $context.UserName + $viewer.bot | Should-Be -Expected $true + } + + It 'Reconstructs a separate typed context from the real encrypted Context store' { + $restored = Get-DiscordContext -Context $contextName + + $restored.GetType().Name | Should-Be -Expected 'DiscordContext' + [object]::ReferenceEquals($restored, $context) | Should-Be -Expected $false + $restored.ID | Should-Be -Expected $contextName + $restored.UserID | Should-Be -Expected $context.UserID + ($restored.Token -is [securestring]) | Should-Be -Expected $true + (Get-DiscordViewer -Context $restored).id | Should-Be -Expected $context.UserID + } + + It 'Uses named and default connections without mixing their stored identity' { + try { + Connect-DiscordAccount -BotToken $token -Name $secondContextName -NotDefault + Switch-DiscordContext -Context $secondContextName + + (Get-DiscordContext).Name | Should-Be -Expected $secondContextName + (Get-DiscordViewer).id | Should-Be -Expected $context.UserID + (Get-DiscordContext -Context $contextName).Name | Should-Be -Expected $contextName + $available = @(Get-DiscordContext -ListAvailable).Name + ($available -contains $contextName) | Should-Be -Expected $true + ($available -contains $secondContextName) | Should-Be -Expected $true + ($available -contains 'Module') | Should-Be -Expected $false + } finally { + Set-DiscordConfig -DefaultContext $originalConfig.DefaultContext -Confirm:$false + if (@(Get-DiscordContext -ListAvailable).Name -contains $secondContextName) { + Disconnect-DiscordAccount -Context $secondContextName -Confirm:$false + } + } + } + + It 'Saves settings through the real Context API without exposing mutable cached state' { + try { + Set-DiscordConfig -RetryCount 0 -RetryInterval 2 -TimeoutSeconds 25 + $config = Get-DiscordConfig + + $config.RetryCount | Should-Be -Expected 0 + $config.RetryInterval | Should-Be -Expected 2 + $config.TimeoutSeconds | Should-Be -Expected 25 + $config.RetryCount = 8 + (Get-DiscordConfig).RetryCount | Should-Be -Expected 0 + } finally { + $restore = @{ + RetryCount = $originalConfig.RetryCount + RetryInterval = $originalConfig.RetryInterval + TimeoutSeconds = $originalConfig.TimeoutSeconds + Confirm = $false + } + Set-DiscordConfig @restore + } + } + + It 'Returns parsed REST results without credential-bearing request metadata' { + $result = Invoke-DiscordAPI -ApiEndpoint '/users/@me' -Context $contextName + + $result.StatusCode | Should-Be -Expected 200 + $result.Response.id | Should-Be -Expected $context.UserID + ($result.PSObject.Properties.Name -join ',') | Should-Be -Expected 'Request,Response,Headers,StatusCode,StatusDescription' + ($result.Request.PSObject.Properties.Name -join ',') | Should-Be -Expected 'Method,Uri' + $result.Request.Uri.AbsoluteUri | Should-Be -Expected 'https://discord.com/api/v10/users/@me' + } + + It 'Accepts a full official API URI and an explicit typed connection' { + $result = Invoke-DiscordAPI -Uri 'https://discord.com/api/v10/users/@me' -Context $context + + $result.StatusCode | Should-Be -Expected 200 + $result.Response.id | Should-Be -Expected $context.UserID + } + + It 'Raises a real HTTP error instead of returning a success-shaped result' { + $request = @{ + ApiEndpoint = '/psmodule-client-nonexistent-route' + Context = $context + RetryCount = 0 + } + + Should-Throw -ScriptBlock { Invoke-DiscordAPI @request } -ExceptionMessage '*HTTP 404*' + } + + It 'Removes only the selected local context without revoking the real bot token' { + try { + Connect-DiscordAccount -BotToken $token -Name $secondContextName -NotDefault + Disconnect-DiscordAccount -Context $secondContextName -Confirm:$false + + (@(Get-DiscordContext -ListAvailable).Name -contains $secondContextName) | Should-Be -Expected $false + (Get-DiscordViewer -Context $contextName).id | Should-Be -Expected $context.UserID + } finally { + if (@(Get-DiscordContext -ListAvailable).Name -contains $secondContextName) { + Disconnect-DiscordAccount -Context $secondContextName -Confirm:$false + } + } + } +} + +Describe 'Configured Discord read-only resources' -Tag 'Integration' { + BeforeAll { + $guildID = [Environment]::GetEnvironmentVariable('DISCORD_TEST_GUILD_ID') + $channelID = [Environment]::GetEnvironmentVariable('DISCORD_TEST_CHANNEL_ID') + $resourceContextName = "DiscordResources-$PID" + $resourceContext = $null + $resourceToken = $null + if (-not [string]::IsNullOrWhiteSpace($guildID) -or -not [string]::IsNullOrWhiteSpace($channelID)) { + $rawToken = [Environment]::GetEnvironmentVariable('DISCORD_BOT_TOKEN') + if ([string]::IsNullOrWhiteSpace($rawToken)) { + throw 'DISCORD_BOT_TOKEN is required to read the configured Discord test resources.' + } + $resourceToken = ConvertTo-SecureString -String $rawToken -AsPlainText -Force + $rawToken = $null + $resourceContext = Connect-DiscordAccount -BotToken $resourceToken -Name $resourceContextName -NotDefault -PassThru + } + } + + AfterAll { + if ($null -ne $resourceContext) { + Disconnect-DiscordAccount -Context $resourceContextName -Confirm:$false + } + if ($null -ne $resourceToken) { + $resourceToken.Dispose() + } + } + + It 'Reads the configured guild with the supported with_counts query' { + if ([string]::IsNullOrWhiteSpace($guildID)) { + Set-ItResult -Skipped -Because 'The optional DISCORD_TEST_GUILD_ID repository variable is not configured.' + return + } + + $query = @{ with_counts = $true } + $result = Invoke-DiscordAPI -ApiEndpoint "/guilds/$guildID" -Query $query -Context $resourceContext + + $result.Response.id | Should-Be -Expected $guildID + $result.Request.Uri.Query | Should-Be -Expected '?with_counts=true' + $query.Count | Should-Be -Expected 1 + } + + It 'Reads the configured channel without changing its contents' { + if ([string]::IsNullOrWhiteSpace($channelID)) { + Set-ItResult -Skipped -Because 'The optional DISCORD_TEST_CHANNEL_ID repository variable is not configured.' + return + } + + $result = Invoke-DiscordAPI -ApiEndpoint "/channels/$channelID" -Context $resourceContext + + $result.Response.id | Should-Be -Expected $channelID + if (-not [string]::IsNullOrWhiteSpace($guildID)) { + $result.Response.guild_id | Should-Be -Expected $guildID + } + } +} diff --git a/tests/Discord.Tests.ps1 b/tests/Discord.Tests.ps1 index 8258bb7..dd1fd1d 100644 --- a/tests/Discord.Tests.ps1 +++ b/tests/Discord.Tests.ps1 @@ -1,4 +1,6 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSReviewUnusedParameter', '', Justification = 'Required for Pester tests' )] @@ -6,11 +8,128 @@ 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Required for Pester tests' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'Noncredential values exercise public validation and WhatIf without authenticating.' +)] [CmdletBinding()] param() -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' +$ErrorActionPreference = 'Stop' + +Describe 'Discord client command surface' { + It 'Exports the reusable client commands' { + $commands = Get-Command -Module Discord -CommandType Function + + foreach ($name in @( + 'Connect-DiscordAccount' + 'Disconnect-DiscordAccount' + 'Get-DiscordContext' + 'Switch-DiscordContext' + 'Get-DiscordViewer' + 'Get-DiscordConfig' + 'Set-DiscordConfig' + 'Invoke-DiscordAPI' + )) { + ($commands.Name -contains $name) | Should-Be -Expected $true + } + } + + It 'Provides the connection aliases' { + (Get-Alias -Name Connect-Discord).ResolvedCommand.Name | Should-Be -Expected 'Connect-DiscordAccount' + (Get-Alias -Name Disconnect-Discord).ResolvedCommand.Name | Should-Be -Expected 'Disconnect-DiscordAccount' + } + + It 'Does not export the template greeting' { + @(Get-Command -Name Get-PSModuleTest -Module Discord -ErrorAction SilentlyContinue).Count | Should-Be -Expected 0 + } + + It 'Accepts credentials only as SecureStrings' { + $command = Get-Command -Name Connect-DiscordAccount + + $command.Parameters.BotToken.ParameterType.FullName | Should-Be -Expected 'System.Security.SecureString' + $command.Parameters.ClientSecret.ParameterType.FullName | Should-Be -Expected 'System.Security.SecureString' + $command.Parameters.ContainsKey('Token') | Should-Be -Expected $false + } +} + +Describe 'Public client validation' { + It 'Rejects unsafe REST destinations' -ForEach @( + @{ Uri = 'http://discord.com/api/v10/users/@me' } + @{ Uri = 'https://example.com/api/v10/users/@me' } + @{ Uri = 'https://discord.com.example.com/api/v10/users/@me' } + @{ Uri = 'https://discord.com:444/api/v10/users/@me' } + @{ Uri = 'https://user@discord.com/api/v10/users/@me' } + @{ Uri = 'https://discord.com/api/oauth2/token' } + @{ Uri = 'https://discord.com/api/v10/../oauth2/token' } + @{ Uri = 'https://discord.com/api/v10/users/@me#fragment' } + ) { + Should-Throw -ScriptBlock { Invoke-DiscordAPI -Uri $Uri } + } + + It 'Rejects absolute paths supplied as an endpoint' { + Should-Throw -ScriptBlock { Invoke-DiscordAPI -ApiEndpoint 'https://example.com/users/@me' } + } + + It 'Rejects query bodies that are not dictionaries' { + Should-Throw -ScriptBlock { Invoke-DiscordAPI -ApiEndpoint '/users/@me' -Body 'not-a-dictionary' } + } + + It 'Rejects wildcard and reserved context names' -ForEach @( + @{ Name = '*' } + @{ Name = 'account?' } + @{ Name = 'Module' } + ) { + Should-Throw -ScriptBlock { Get-DiscordContext -Context $Name } + } + + It 'Requires identify and rejects installation scopes before user consent' -ForEach @( + @{ Scope = @('guilds') } + @{ Scope = @('identify', 'bot') } + @{ Scope = @('identify', 'webhook.incoming') } + ) { + $connection = @{ + ClientID = '123456789012345678' + ClientSecret = ConvertTo-SecureString -String 'noncredential-test-value' -AsPlainText -Force + Scope = $Scope + WhatIf = $true + } + + Should-Throw -ScriptBlock { Connect-DiscordAccount @connection } + } + + It 'Rejects non-loopback, privileged, or ambiguous OAuth2 redirects' -ForEach @( + @{ RedirectUri = 'http://example.com:53682/' } + @{ RedirectUri = 'https://localhost:53682/' } + @{ RedirectUri = 'http://localhost:80/' } + @{ RedirectUri = 'http://localhost:53682/callback' } + @{ RedirectUri = 'http://localhost:53682/?other=value' } + @{ RedirectUri = 'http://localhost:53682/#fragment' } + ) { + $connection = @{ + ClientID = '123456789012345678' + ClientSecret = ConvertTo-SecureString -String 'noncredential-test-value' -AsPlainText -Force + RedirectUri = $RedirectUri + WhatIf = $true + } + + Should-Throw -ScriptBlock { Connect-DiscordAccount @connection } + } + + It 'Previews bot sign-in without contacting Discord or creating a context' { + $token = ConvertTo-SecureString -String 'noncredential-test-value' -AsPlainText -Force + + @(Connect-DiscordAccount -BotToken $token -WhatIf).Count | Should-Be -Expected 0 + } + + It 'Previews a mutating REST call without requiring credentials' { + $request = @{ + ApiEndpoint = '/users/@me' + Method = 'PATCH' + Body = @{ username = 'unused-preview-value' } + WhatIf = $true + } + + @(Invoke-DiscordAPI @request).Count | Should-Be -Expected 0 } }