diff --git a/DISCORD_MIRROR_DEPLOYMENT.md b/DISCORD_MIRROR_DEPLOYMENT.md
new file mode 100644
index 000000000..350cb6c9e
--- /dev/null
+++ b/DISCORD_MIRROR_DEPLOYMENT.md
@@ -0,0 +1,83 @@
+# Discord Mirror Deployment Guide for PowerShell.org
+
+This repository contains a PowerShell-based Discord mirror that publishes selected Discord channel content into the Hugo site under `/discord/`.
+
+## What this does
+
+- Pulls messages from **specific allowlisted channels** in the PowerShell.org Discord server.
+- Applies **moderation controls** before any content is published.
+- Generates Hugo content pages in `content/discord/`.
+- Generates search assets in `static/discord/`.
+- Leaves all attachments and images hosted by Discord and links back to them.
+
+## Required GitHub secret
+
+Add this repository secret:
+
+- `DISCORD_BOT_TOKEN`
+
+## Required config updates
+
+Edit `config/discord-mirror.json`:
+
+- Replace `discord.guildId`
+- Replace each placeholder channel ID
+- Tune moderation mode per channel
+- Tune `minMessageAgeMinutes` per channel
+- Optionally adjust regex filters
+
+## Recommended moderation modes
+
+### `all`
+Publish everything in that channel after the minimum age filter.
+
+Use for:
+- announcements
+- release channels
+- moderator-curated channels only
+
+### `reaction`
+Only publish messages that have the approval reaction, such as ✅.
+
+Use for:
+- help channels
+- showcase channels
+- Q&A highlights
+
+### `prefix`
+Only publish messages whose content starts with a configured prefix, such as `[publish]`.
+
+Use for:
+- moderator repost channels
+- copy-edited summaries
+
+### `author-role`
+Only publish messages authored by users with one of the configured role names.
+
+Use for:
+- staff summaries
+- trusted publisher channels
+
+### `any`
+Publish a message if any of the enabled approval mechanisms match.
+
+Use for:
+- flexible moderation workflows
+
+## Workflow behavior
+
+- On `main` branch pushes, the workflow runs the Discord exporter if `DISCORD_BOT_TOKEN` is present.
+- On pull requests or when the secret is missing, the Hugo build still runs but the Discord export step is skipped.
+
+## Validation checklist
+
+1. Confirm the bot can read the selected channels.
+2. Confirm the bot can read message history.
+3. Confirm `https://powershell.org/discord/` renders.
+4. Confirm `https://powershell.org/discord/search/` loads search results.
+5. Confirm no private or unapproved messages appear.
+6. Confirm attachment links point to Discord URLs.
+
+## Operational advice
+
+Do not point this at high-noise general chat and hope for the best. That is how you build a searchable landfill.
diff --git a/DISCORD_MIRROR_MODERATION_GUIDE.md b/DISCORD_MIRROR_MODERATION_GUIDE.md
new file mode 100644
index 000000000..6c0599de1
--- /dev/null
+++ b/DISCORD_MIRROR_MODERATION_GUIDE.md
@@ -0,0 +1,63 @@
+# Discord Mirror Moderation Guide (v5)
+
+This version adds practical publication controls so PowerShell.org can expose selected Discord content on the public web without blindly dumping channel history.
+
+## Controls available
+
+### 1. Channel allowlist
+Only channels listed in `config/discord-mirror.json` are processed.
+
+### 2. Per-channel moderation mode
+Each channel can choose one of these modes:
+
+- `all`
+- `reaction`
+- `prefix`
+- `author-role`
+- `any`
+
+### 3. Minimum message age
+`minMessageAgeMinutes` delays publication so moderators have time to correct or remove content.
+
+Examples:
+- `60` for announcements
+- `1440` for support highlights
+- `10080` for weekly editorial review
+
+### 4. Regex filters
+Use `excludeRegex` to suppress command spam, bot command invocations, or unwanted patterns.
+Use `includeRegex` to only publish messages that match a known format.
+
+### 5. Optional thread capture
+`includeThreads` can be enabled per channel, but it is off by default because the current target is mostly normal text channels.
+
+## Suggested policies for PowerShell.org
+
+### Announcements
+- moderation: `all`
+- age: `60`
+- threads: `false`
+
+### Help highlights
+- moderation: `reaction`
+- reaction: `✅`
+- age: `1440`
+- threads: `false`
+
+### Moderator summaries
+- moderation: `prefix`
+- prefix: `[publish]`
+- age: `60`
+- threads: `false`
+
+### Staff-only publisher channel
+- moderation: `author-role`
+- roles: `Moderator`, `Admin`, `Discord Team`
+- age: `60`
+- threads: `false`
+
+## Recommended rollout
+
+Start with one low-risk channel such as announcements, verify output, then add one curated help channel using `reaction` mode.
+
+That gives you a public knowledge layer instead of a public transcript of everyone's stream of consciousness.
diff --git a/DISCORD_MIRROR_SITE_OWNER_QUICKSTART.md b/DISCORD_MIRROR_SITE_OWNER_QUICKSTART.md
new file mode 100644
index 000000000..0a1551796
--- /dev/null
+++ b/DISCORD_MIRROR_SITE_OWNER_QUICKSTART.md
@@ -0,0 +1,9 @@
+# Discord Mirror Quick Start
+
+1. Add the `DISCORD_BOT_TOKEN` secret to the GitHub repository.
+2. Update `config/discord-mirror.json` with the real guild ID and channel IDs.
+3. Give the Discord bot **View Channel** and **Read Message History** in the selected channels.
+4. Push the changes to `main`.
+5. Verify the GitHub Actions workflow succeeds.
+6. Open `/discord/` and `/discord/search/` on the site.
+7. Verify moderation controls are working before announcing the feature publicly.
diff --git a/config/discord-mirror.json b/config/discord-mirror.json
new file mode 100644
index 000000000..062b56753
--- /dev/null
+++ b/config/discord-mirror.json
@@ -0,0 +1,79 @@
+{
+ "site": {
+ "title": "PowerShell.org Discord Archive",
+ "baseUrl": "https://powershell.org/discord",
+ "sectionPath": "discord",
+ "footerText": "Selected Discord content is published to the public web for search and discovery. Attachments and images remain hosted by Discord.",
+ "searchDescription": "Search approved Discord archive content mirrored from selected PowerShell.org Discord channels."
+ },
+ "discord": {
+ "guildId": "1488875093873397832",
+ "apiBaseUrl": "https://discord.com/api/v10",
+ "userAgent": "PowerShellOrgDiscordMirror/5.0"
+ },
+ "export": {
+ "outputContentDir": "content/discord",
+ "outputStaticDir": "static/discord",
+ "searchIndexFileName": "search-index.json",
+ "includeBotMessages": false,
+ "sanitizeMentions": true,
+ "maxMessagesPerChannel": 1500,
+ "defaultMinMessageAgeMinutes": 1440,
+ "defaultModerationMode": "reaction",
+ "defaultApprovalReaction": "\u2705",
+ "defaultApprovalPrefix": "[publish]",
+ "defaultAuthorRoleNames": [
+ "Moderator",
+ "Admin",
+ "Discord Team"
+ ],
+ "defaultExcludeRegex": [
+ "^/",
+ "^!",
+ "^\\\\?help"
+ ],
+ "generatedBy": "tools/discord-mirror/Export-DiscordMirror.ps1"
+ },
+ "channels": [
+ {
+ "id": "1488875094938746884",
+ "slug": "announcements",
+ "title": "Announcements",
+ "description": "Approved announcement messages mirrored from Discord.",
+ "enabled": true,
+ "includeThreads": false,
+ "maxMessages": 500,
+ "minMessageAgeMinutes": 60,
+ "moderationMode": "all",
+ "approvalReaction": "\u2705",
+ "approvalPrefix": "[publish]",
+ "authorRoleNames": [
+ "Moderator",
+ "Admin"
+ ],
+ "excludeRegex": []
+ },
+ {
+ "id": "1489393262191185981",
+ "slug": "TechTalk-PowerShell",
+ "title": "TechTalk-PowerShell",
+ "description": "Curated PowerShell message highlights approved for public publishing.",
+ "enabled": true,
+ "includeThreads": false,
+ "maxMessages": 2000,
+ "minMessageAgeMinutes": 1440,
+ "moderationMode": "all",
+ "approvalReaction": "\u2705",
+ "approvalPrefix": "[publish]",
+ "authorRoleNames": [
+ "Moderator",
+ "Admin"
+ ],
+ "excludeRegex": [
+ "^/",
+ "^!"
+ ],
+ "includeRegex": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/hugo.yaml b/hugo.yaml
index c8ca0fd06..0dd0d5536 100644
--- a/hugo.yaml
+++ b/hugo.yaml
@@ -57,6 +57,9 @@ menu:
- name: "Forums"
url: "https://forums.powershell.org"
weight: 60
+ - name: "Discord Archive"
+ url: "/discord/"
+ weight: 62
# Site parameters
params:
diff --git a/static/discord/search-index.json b/static/discord/search-index.json
new file mode 100644
index 000000000..fe51488c7
--- /dev/null
+++ b/static/discord/search-index.json
@@ -0,0 +1 @@
+[]
diff --git a/static/discord/search.js b/static/discord/search.js
new file mode 100644
index 000000000..23252884d
--- /dev/null
+++ b/static/discord/search.js
@@ -0,0 +1 @@
+// Generated by tools/discord-mirror/Export-DiscordMirror.ps1
diff --git a/static/discord/styles.css b/static/discord/styles.css
new file mode 100644
index 000000000..709d0e20f
--- /dev/null
+++ b/static/discord/styles.css
@@ -0,0 +1 @@
+/* Generated by tools/discord-mirror/Export-DiscordMirror.ps1 */
diff --git a/tools/discord-mirror/Export-DiscordMirror.ps1 b/tools/discord-mirror/Export-DiscordMirror.ps1
new file mode 100644
index 000000000..7e6131638
--- /dev/null
+++ b/tools/discord-mirror/Export-DiscordMirror.ps1
@@ -0,0 +1,658 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $false)]
+ [string]$ConfigPath = "config/discord-mirror.json",
+
+ [Parameter(Mandatory = $false)]
+ [string]$BotToken = $env:DISCORD_BOT_TOKEN
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+function Write-Log {
+ param([string]$Message)
+ Write-Host "[discord-mirror] $Message"
+}
+
+function Get-JsonFile {
+ param([string]$Path)
+ if (-not (Test-Path -LiteralPath $Path)) {
+ throw "Config file not found: $Path"
+ }
+ return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -Depth 100)
+}
+
+function Ensure-Directory {
+ param([string]$Path)
+ if (-not (Test-Path -LiteralPath $Path)) {
+ New-Item -ItemType Directory -Path $Path -Force | Out-Null
+ }
+}
+
+function Invoke-DiscordApi {
+ param(
+ [string]$Uri,
+ [string]$BotToken,
+ [string]$UserAgent
+ )
+
+ $headers = @{
+ Authorization = "Bot $BotToken"
+ 'User-Agent' = $UserAgent
+ }
+
+ Start-Sleep -Milliseconds 150
+ return Invoke-RestMethod -Method Get -Uri $Uri -Headers $headers
+}
+
+function ConvertTo-Slug {
+ param([string]$Value)
+ if ([string]::IsNullOrWhiteSpace($Value)) { return 'discord-channel' }
+ $slug = $Value.ToLowerInvariant()
+ $slug = [regex]::Replace($slug, '[^a-z0-9]+', '-')
+ $slug = $slug.Trim('-')
+ if ([string]::IsNullOrWhiteSpace($slug)) { return 'discord-channel' }
+ return $slug
+}
+
+function HtmlEncode {
+ param([AllowNull()][string]$Value)
+ if ($null -eq $Value) { return '' }
+ return [System.Net.WebUtility]::HtmlEncode($Value)
+}
+
+function Convert-DiscordMentions {
+ param(
+ [string]$Text,
+ [pscustomobject]$Message,
+ [hashtable]$ChannelLookup,
+ [bool]$SanitizeMentions
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
+ $output = $Text
+
+ if ($SanitizeMentions) {
+ if ($Message.mentions) {
+ foreach ($mention in $Message.mentions) {
+ $display = if ($mention.global_name) { $mention.global_name } elseif ($mention.username) { $mention.username } else { 'user' }
+ $output = $output -replace "<@!?$($mention.id)>", "@$display"
+ }
+ }
+
+ $roleMentions = @($Message.mention_roles)
+ foreach ($roleId in $roleMentions) {
+ $output = $output -replace "<@&$roleId>", '@role'
+ }
+
+ foreach ($key in $ChannelLookup.Keys) {
+ $channelName = $ChannelLookup[$key]
+ $output = $output -replace "<#${key}>", "#$channelName"
+ }
+
+ $output = $output -replace '@everyone', 'everyone'
+ $output = $output -replace '@here', 'here'
+ }
+
+ return $output
+}
+
+function Convert-DiscordContentToHtml {
+ param(
+ [string]$Text,
+ [pscustomobject]$Message,
+ [hashtable]$ChannelLookup,
+ [bool]$SanitizeMentions
+ )
+
+ $resolved = Convert-DiscordMentions -Text $Text -Message $Message -ChannelLookup $ChannelLookup -SanitizeMentions:$SanitizeMentions
+ $encoded = HtmlEncode -Value $resolved
+ $encoded = [regex]::Replace($encoded, '(https?://\S+)', '$1')
+ $encoded = $encoded -replace "`r?`n", '
'
+ return $encoded
+}
+
+function Get-MessageAuthorName {
+ param([pscustomobject]$Message)
+ if ($Message.member -and $Message.member.nick) { return $Message.member.nick }
+ if ($Message.author.global_name) { return $Message.author.global_name }
+ return $Message.author.username
+}
+
+function Get-MessageRoleNames {
+ param(
+ [pscustomobject]$Message,
+ [hashtable]$RoleLookup
+ )
+ $names = New-Object System.Collections.Generic.List[string]
+ if ($Message.member -and $Message.member.roles) {
+ foreach ($roleId in $Message.member.roles) {
+ if ($RoleLookup.ContainsKey([string]$roleId)) {
+ $names.Add([string]$RoleLookup[[string]$roleId])
+ }
+ }
+ }
+ return @($names)
+}
+
+function Test-MessageModeration {
+ param(
+ [pscustomobject]$Message,
+ [pscustomobject]$ChannelConfig,
+ [pscustomobject]$GlobalExport,
+ [hashtable]$RoleLookup
+ )
+
+ $moderationMode = if ($ChannelConfig.moderationMode) { [string]$ChannelConfig.moderationMode } else { [string]$GlobalExport.defaultModerationMode }
+ $approvalReaction = if ($ChannelConfig.approvalReaction) { [string]$ChannelConfig.approvalReaction } else { [string]$GlobalExport.defaultApprovalReaction }
+ $approvalPrefix = if ($ChannelConfig.approvalPrefix) { [string]$ChannelConfig.approvalPrefix } else { [string]$GlobalExport.defaultApprovalPrefix }
+ $authorRoleNames = @()
+ if ($ChannelConfig.authorRoleNames) { $authorRoleNames = @($ChannelConfig.authorRoleNames) }
+ elseif ($GlobalExport.defaultAuthorRoleNames) { $authorRoleNames = @($GlobalExport.defaultAuthorRoleNames) }
+
+ $content = [string]$Message.content
+ $roleNames = @(Get-MessageRoleNames -Message $Message -RoleLookup $RoleLookup)
+ $hasReaction = $false
+ if ($Message.reactions) {
+ foreach ($reaction in $Message.reactions) {
+ $emojiName = if ($reaction.emoji.name) { [string]$reaction.emoji.name } else { '' }
+ if ($emojiName -eq $approvalReaction) {
+ $hasReaction = $true
+ break
+ }
+ }
+ }
+ $hasPrefix = $false
+ if (-not [string]::IsNullOrWhiteSpace($approvalPrefix)) {
+ $hasPrefix = $content.TrimStart().StartsWith($approvalPrefix, [System.StringComparison]::OrdinalIgnoreCase)
+ }
+ $authorApproved = $false
+ if ($authorRoleNames.Count -gt 0 -and $roleNames.Count -gt 0) {
+ foreach ($roleName in $roleNames) {
+ if ($authorRoleNames -contains $roleName) {
+ $authorApproved = $true
+ break
+ }
+ }
+ }
+
+ switch ($moderationMode.ToLowerInvariant()) {
+ 'all' { return $true }
+ 'reaction' { return $hasReaction }
+ 'prefix' { return $hasPrefix }
+ 'author-role' { return $authorApproved }
+ 'any' { return ($hasReaction -or $hasPrefix -or $authorApproved) }
+ default { return $false }
+ }
+}
+
+function Test-MessageFilters {
+ param(
+ [pscustomobject]$Message,
+ [pscustomobject]$ChannelConfig,
+ [pscustomobject]$GlobalExport,
+ [datetimeoffset]$NowUtc,
+ [hashtable]$RoleLookup
+ )
+
+ if (-not $GlobalExport.includeBotMessages -and $Message.author.bot) {
+ return $false
+ }
+
+ if ([string]::IsNullOrWhiteSpace([string]$Message.content) -and (-not $Message.attachments)) {
+ return $false
+ }
+
+ $created = [datetimeoffset]::Parse($Message.timestamp).ToUniversalTime()
+ $requiredAge = if ($ChannelConfig.minMessageAgeMinutes) { [int]$ChannelConfig.minMessageAgeMinutes } else { [int]$GlobalExport.defaultMinMessageAgeMinutes }
+ if ($requiredAge -gt 0) {
+ $ageMinutes = ($NowUtc - $created).TotalMinutes
+ if ($ageMinutes -lt $requiredAge) {
+ return $false
+ }
+ }
+
+ $excludeRegex = @()
+ if ($ChannelConfig.excludeRegex) { $excludeRegex = @($ChannelConfig.excludeRegex) }
+ elseif ($GlobalExport.defaultExcludeRegex) { $excludeRegex = @($GlobalExport.defaultExcludeRegex) }
+
+ foreach ($pattern in $excludeRegex) {
+ if (-not [string]::IsNullOrWhiteSpace($pattern) -and [regex]::IsMatch([string]$Message.content, [string]$pattern)) {
+ return $false
+ }
+ }
+
+ if ($ChannelConfig.includeRegex) {
+ $matched = $false
+ foreach ($pattern in @($ChannelConfig.includeRegex)) {
+ if (-not [string]::IsNullOrWhiteSpace($pattern) -and [regex]::IsMatch([string]$Message.content, [string]$pattern)) {
+ $matched = $true
+ break
+ }
+ }
+ if (-not $matched) {
+ return $false
+ }
+ }
+
+ return (Test-MessageModeration -Message $Message -ChannelConfig $ChannelConfig -GlobalExport $GlobalExport -RoleLookup $RoleLookup)
+}
+
+function Get-ChannelMessages {
+ param(
+ [string]$ApiBaseUrl,
+ [string]$ChannelId,
+ [string]$BotToken,
+ [string]$UserAgent,
+ [int]$MaxMessages
+ )
+
+ $messages = New-Object System.Collections.Generic.List[object]
+ $before = $null
+ $pageSize = 100
+
+ do {
+ $uri = "$ApiBaseUrl/channels/$ChannelId/messages?limit=$pageSize"
+ if ($before) {
+ $uri += "&before=$before"
+ }
+ $batch = @(Invoke-DiscordApi -Uri $uri -BotToken $BotToken -UserAgent $UserAgent)
+ if (-not $batch -or $batch.Count -eq 0) {
+ break
+ }
+ foreach ($item in $batch) {
+ $messages.Add($item)
+ if ($messages.Count -ge $MaxMessages) { break }
+ }
+ $before = $batch[-1].id
+ } while ($batch.Count -eq $pageSize -and $messages.Count -lt $MaxMessages)
+
+ $result = @($messages)
+ [array]::Reverse($result)
+ return $result
+}
+
+function Get-PublicThreadsForChannel {
+ param(
+ [string]$ApiBaseUrl,
+ [string]$ChannelId,
+ [string]$BotToken,
+ [string]$UserAgent
+ )
+
+ $threads = New-Object System.Collections.Generic.List[object]
+
+ try {
+ $active = Invoke-DiscordApi -Uri "$ApiBaseUrl/channels/$ChannelId/threads/active" -BotToken $BotToken -UserAgent $UserAgent
+ if ($active.threads) {
+ foreach ($thread in @($active.threads)) { $threads.Add($thread) }
+ }
+ }
+ catch {
+ Write-Log "Active threads lookup failed for $ChannelId: $($_.Exception.Message)"
+ }
+
+ try {
+ $archived = Invoke-DiscordApi -Uri "$ApiBaseUrl/channels/$ChannelId/threads/archived/public?limit=100" -BotToken $BotToken -UserAgent $UserAgent
+ if ($archived.threads) {
+ foreach ($thread in @($archived.threads)) { $threads.Add($thread) }
+ }
+ }
+ catch {
+ Write-Log "Archived threads lookup failed for $ChannelId: $($_.Exception.Message)"
+ }
+
+ $distinct = @{}
+ foreach ($thread in $threads) {
+ $distinct[[string]$thread.id] = $thread
+ }
+ return @($distinct.Values)
+}
+
+function Get-GuildRoles {
+ param(
+ [string]$ApiBaseUrl,
+ [string]$GuildId,
+ [string]$BotToken,
+ [string]$UserAgent
+ )
+
+ $roles = @(Invoke-DiscordApi -Uri "$ApiBaseUrl/guilds/$GuildId/roles" -BotToken $BotToken -UserAgent $UserAgent)
+ $lookup = @{}
+ foreach ($role in $roles) {
+ $lookup[[string]$role.id] = [string]$role.name
+ }
+ return $lookup
+}
+
+function Get-ChannelLookup {
+ param(
+ [string]$ApiBaseUrl,
+ [string]$GuildId,
+ [string]$BotToken,
+ [string]$UserAgent
+ )
+ $channels = @(Invoke-DiscordApi -Uri "$ApiBaseUrl/guilds/$GuildId/channels" -BotToken $BotToken -UserAgent $UserAgent)
+ $lookup = @{}
+ foreach ($channel in $channels) {
+ $lookup[[string]$channel.id] = [string]$channel.name
+ }
+ return $lookup
+}
+
+function New-MessageHtmlBlock {
+ param(
+ [pscustomobject]$Message,
+ [string]$GuildId,
+ [string]$ChannelId,
+ [hashtable]$ChannelLookup,
+ [bool]$SanitizeMentions
+ )
+
+ $author = HtmlEncode -Value (Get-MessageAuthorName -Message $Message)
+ $timestamp = [datetimeoffset]::Parse($Message.timestamp).ToString('yyyy-MM-dd HH:mm') + ' UTC'
+ $messageUrl = "https://discord.com/channels/$GuildId/$ChannelId/$($Message.id)"
+ $contentHtml = Convert-DiscordContentToHtml -Text ([string]$Message.content) -Message $Message -ChannelLookup $ChannelLookup -SanitizeMentions:$SanitizeMentions
+
+ $attachmentsHtml = ''
+ if ($Message.attachments) {
+ $items = foreach ($attachment in @($Message.attachments)) {
+ $name = if ($attachment.filename) { HtmlEncode -Value ([string]$attachment.filename) } else { 'attachment' }
+ $url = HtmlEncode -Value ([string]$attachment.url)
+ "
$($Site.searchDescription)
+ + +Search index could not be loaded.
'; + return; + } + + const render = (items) => { + if (!items.length) { + results.innerHTML = 'No results found.
'; + return; + } + results.innerHTML = items.map(item => ` + + `).join(''); + }; + + input.addEventListener('input', () => { + const query = input.value.trim().toLowerCase(); + if (!query) { + results.innerHTML = 'Start typing to search.
'; + return; + } + const filtered = index.filter(item => item.text.toLowerCase().includes(query)).slice(0, 100); + render(filtered); + }); + + results.innerHTML = 'Start typing to search.
'; +})(); +"@ + Set-Content -LiteralPath (Join-Path $StaticDir 'search.js') -Value $searchJs -Encoding UTF8 + + $robots = "User-agent: *`nAllow: /`nSitemap: https://powershell.org/sitemap.xml`n" + Set-Content -LiteralPath (Join-Path (Split-Path $StaticDir -Parent) 'robots.txt') -Value $robots -Encoding UTF8 +} + +function Remove-GeneratedChannelDirectories { + param([string]$ContentDir) + if (-not (Test-Path -LiteralPath $ContentDir)) { return } + Get-ChildItem -LiteralPath $ContentDir -Directory | Where-Object { $_.Name -ne 'search' } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + Get-ChildItem -LiteralPath $ContentDir -File | Where-Object { $_.Name -ne '_index.md' } | Remove-Item -Force -ErrorAction SilentlyContinue +} + +$config = Get-JsonFile -Path $ConfigPath +if ([string]::IsNullOrWhiteSpace($BotToken)) { + throw 'DISCORD_BOT_TOKEN was not supplied. Set the secret or pass -BotToken.' +} + +$site = $config.site +$discord = $config.discord +$export = $config.export +$enabledChannels = @($config.channels | Where-Object { $_.enabled -eq $true -and -not [string]::IsNullOrWhiteSpace([string]$_.id) -and -not [string]$_.id.StartsWith('REPLACE_') }) +if ($enabledChannels.Count -eq 0) { + throw 'No enabled channels were configured. Update config/discord-mirror.json.' +} + +$contentDir = [System.IO.Path]::GetFullPath($export.outputContentDir) +$staticDir = [System.IO.Path]::GetFullPath($export.outputStaticDir) +Ensure-Directory -Path $contentDir +Ensure-Directory -Path $staticDir +Remove-GeneratedChannelDirectories -ContentDir $contentDir + +$roleLookup = Get-GuildRoles -ApiBaseUrl $discord.apiBaseUrl -GuildId ([string]$discord.guildId) -BotToken $BotToken -UserAgent ([string]$discord.userAgent) +$channelLookup = Get-ChannelLookup -ApiBaseUrl $discord.apiBaseUrl -GuildId ([string]$discord.guildId) -BotToken $BotToken -UserAgent ([string]$discord.userAgent) +$nowUtc = [datetimeoffset]::UtcNow +$channelPages = New-Object System.Collections.Generic.List[object] +$searchIndex = New-Object System.Collections.Generic.List[object] + +foreach ($channel in $enabledChannels) { + $slug = if ($channel.slug) { [string]$channel.slug } else { ConvertTo-Slug -Value ([string]$channel.title) } + $maxMessages = if ($channel.maxMessages) { [int]$channel.maxMessages } else { [int]$export.maxMessagesPerChannel } + Write-Log "Processing channel $($channel.id) ($slug)" + $messages = @(Get-ChannelMessages -ApiBaseUrl $discord.apiBaseUrl -ChannelId ([string]$channel.id) -BotToken $BotToken -UserAgent ([string]$discord.userAgent) -MaxMessages $maxMessages) + + $approved = foreach ($message in $messages) { + if (Test-MessageFilters -Message $message -ChannelConfig $channel -GlobalExport $export -NowUtc $nowUtc -RoleLookup $roleLookup) { + $message + } + } + + if ($channel.includeThreads -eq $true) { + $threads = @(Get-PublicThreadsForChannel -ApiBaseUrl $discord.apiBaseUrl -ChannelId ([string]$channel.id) -BotToken $BotToken -UserAgent ([string]$discord.userAgent)) + foreach ($thread in $threads) { + $threadMessages = @(Get-ChannelMessages -ApiBaseUrl $discord.apiBaseUrl -ChannelId ([string]$thread.id) -BotToken $BotToken -UserAgent ([string]$discord.userAgent) -MaxMessages $maxMessages) + foreach ($msg in $threadMessages) { + if (Test-MessageFilters -Message $msg -ChannelConfig $channel -GlobalExport $export -NowUtc $nowUtc -RoleLookup $roleLookup) { + $approved += $msg + } + } + } + } + + $approved = @($approved | Sort-Object { [datetimeoffset]::Parse($_.timestamp) }) + $page = New-ChannelPage -ChannelConfig $channel -Messages $approved -GuildId ([string]$discord.guildId) -ContentDir $contentDir -SectionPath ([string]$site.sectionPath) -ChannelLookup $channelLookup -SanitizeMentions:([bool]$export.sanitizeMentions) + $channelPages.Add($page) + + foreach ($message in $approved) { + $text = Convert-DiscordMentions -Text ([string]$message.content) -Message $message -ChannelLookup $channelLookup -SanitizeMentions:([bool]$export.sanitizeMentions) + if ([string]::IsNullOrWhiteSpace($text)) { continue } + $excerpt = $text + if ($excerpt.Length -gt 220) { $excerpt = $excerpt.Substring(0,220) + '…' } + $searchIndex.Add([pscustomobject]@{ + channel = $page.title + url = "$($page.url)#msg-$($message.id)" + author = (Get-MessageAuthorName -Message $message) + timestamp = [datetimeoffset]::Parse($message.timestamp).ToString('yyyy-MM-dd HH:mm') + ' UTC' + text = $text + excerpt = $excerpt + }) + } +} + +New-SectionLandingPage -ChannelPages @($channelPages) -ContentDir $contentDir -Site $site -SectionPath ([string]$site.sectionPath) +Write-StaticAssets -StaticDir $staticDir -SearchIndexFileName ([string]$export.searchIndexFileName) -SectionPath ([string]$site.sectionPath) -FooterText ([string]$site.footerText) +$searchIndex | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $staticDir ([string]$export.searchIndexFileName)) -Encoding UTF8 +Write-Log "Export complete. Generated $($channelPages.Count) channel pages and $($searchIndex.Count) search records."