| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142 |
- [CmdletBinding()]
- param(
- [string]$ConfigPath = (Join-Path $PSScriptRoot 'config\central-feedback.config.dpapi'),
- [string]$AuditPath = (Join-Path $PSScriptRoot 'data\submissions.jsonl')
- )
- $ErrorActionPreference = 'Stop'
- . (Join-Path $PSScriptRoot 'Common.ps1')
- Add-Type -AssemblyName System.Net.Http
- function Invoke-FeishuJson {
- param([System.Net.Http.HttpClient]$Client, [string]$Uri, [object]$Body)
- $content = New-Object -TypeName System.Net.Http.StringContent -ArgumentList @(($Body | ConvertTo-Json -Compress -Depth 6), [Text.Encoding]::UTF8, 'application/json')
- try {
- $response = $Client.PostAsync($Uri, $content).GetAwaiter().GetResult()
- $raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
- }
- finally { $content.Dispose() }
- if (-not $response.IsSuccessStatusCode) { throw (New-Object -TypeName Net.WebException -ArgumentList "Feishu HTTP $([int]$response.StatusCode)") }
- $data = $raw | ConvertFrom-Json
- if ($data.code -ne 0) { throw (New-Object -TypeName InvalidOperationException -ArgumentList "Feishu code $($data.code)") }
- $data
- }
- function Invoke-FeishuFileSend {
- param([pscustomobject]$Config, [byte[]]$FileBytes, [string]$FileName)
- $client = New-Object System.Net.Http.HttpClient
- try {
- $token = Invoke-FeishuJson -Client $client -Uri 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' -Body @{ app_id = [string]$Config.app_id; app_secret = [string]$Config.app_secret }
- $client.DefaultRequestHeaders.Authorization = New-Object -TypeName System.Net.Http.Headers.AuthenticationHeaderValue -ArgumentList @('Bearer', [string]$token.tenant_access_token)
- $form = New-Object System.Net.Http.MultipartFormDataContent
- try {
- $form.Add((New-Object -TypeName System.Net.Http.StringContent -ArgumentList 'stream'), 'file_type')
- $form.Add((New-Object -TypeName System.Net.Http.StringContent -ArgumentList @($FileName, [Text.Encoding]::UTF8)), 'file_name')
- $fileContent = New-Object -TypeName System.Net.Http.ByteArrayContent -ArgumentList (,$FileBytes)
- $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream')
- $form.Add($fileContent, 'file', $FileName)
- $uploadResponse = $client.PostAsync('https://open.feishu.cn/open-apis/im/v1/files', $form).GetAwaiter().GetResult()
- $uploadRaw = $uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult()
- }
- finally { $form.Dispose() }
- if (-not $uploadResponse.IsSuccessStatusCode) { throw (New-Object -TypeName Net.WebException -ArgumentList "Feishu upload HTTP $([int]$uploadResponse.StatusCode)") }
- $upload = $uploadRaw | ConvertFrom-Json
- if ($upload.code -ne 0 -or [string]::IsNullOrWhiteSpace($upload.data.file_key)) { throw (New-Object -TypeName InvalidOperationException -ArgumentList "Feishu upload code $($upload.code)") }
- return Invoke-FeishuJson -Client $client -Uri 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id' -Body @{
- receive_id = [string]$Config.chat_id
- msg_type = 'file'
- content = (@{ file_key = [string]$upload.data.file_key; file_name = $FileName } | ConvertTo-Json -Compress)
- }
- }
- finally { $client.Dispose() }
- }
- function Test-AlreadySent {
- param([string]$SubmissionId, [string]$Path)
- if (-not (Test-Path -LiteralPath $Path)) { return $false }
- return [bool](Select-String -LiteralPath $Path -Pattern ('"submission_id":"' + $SubmissionId + '"') -SimpleMatch -Quiet)
- }
- function Add-AuditEntry {
- param([hashtable]$Entry, [string]$Path)
- $directory = Split-Path -Parent $Path
- New-Item -ItemType Directory -Force -Path $directory | Out-Null
- Add-Content -LiteralPath $Path -Value ($Entry | ConvertTo-Json -Compress) -Encoding UTF8
- }
- $config = Get-ProtectedConfig -Path $ConfigPath
- if (-not $config.listen_prefix -or -not $config.app_id -or -not $config.app_secret -or -not $config.chat_id) { throw 'Server configuration is incomplete.' }
- $listener = New-Object Net.HttpListener
- $listener.Prefixes.Add([string]$config.listen_prefix)
- $listener.Start()
- Write-Output "Central feedback sender is listening on $($config.listen_prefix)"
- try {
- while ($listener.IsListening) {
- $context = $listener.GetContext()
- try {
- $request = $context.Request
- if ($request.HttpMethod -eq 'GET' -and $request.Url.AbsolutePath -eq '/health') {
- Write-HttpJson -Context $context -StatusCode 200 -Body @{ status = 'ok' }
- continue
- }
- if ($request.HttpMethod -ne 'POST' -or $request.Url.AbsolutePath -ne '/v1/feedback') {
- Write-HttpJson -Context $context -StatusCode 404 -Body @{ status = 'not_found' }
- continue
- }
- if ($request.ContentLength64 -le 0 -or $request.ContentLength64 -gt 15MB -or $request.ContentType -notlike 'application/json*') {
- Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_request' }
- continue
- }
- $reader = New-Object -TypeName IO.StreamReader -ArgumentList @($request.InputStream, [Text.Encoding]::UTF8)
- try { $payload = $reader.ReadToEnd() | ConvertFrom-Json }
- finally { $reader.Dispose() }
- $projectId = [string]$payload.project_id
- $recordVersion = [string]$payload.record_version
- $trigger = [string]$payload.trigger
- $fileName = [string]$payload.file_name
- $metadataChecks = [ordered]@{
- # 项目端 Agent 已执行正式 Hook、授权与版本规则;中央端只保留传输与审计所需的最小校验,避免格式差异阻断发送。
- project_id = [bool](-not [string]::IsNullOrWhiteSpace($projectId) -and $projectId.Length -le 128)
- record_version = [bool](-not [string]::IsNullOrWhiteSpace($recordVersion) -and $recordVersion.Length -le 128)
- trigger = [bool](-not [string]::IsNullOrWhiteSpace($trigger) -and $trigger.Length -le 128)
- file_name = [bool](-not [string]::IsNullOrWhiteSpace($fileName) -and $fileName.Length -le 200 -and $fileName -match '\.md$' -and [IO.Path]::GetFileName($fileName) -eq $fileName)
- }
- $invalidFields = @($metadataChecks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key })
- if ($invalidFields.Count -gt 0) {
- Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_metadata'; invalid_fields = $invalidFields }
- continue
- }
- try { $fileBytes = [Convert]::FromBase64String([string]$payload.file_base64) }
- catch { Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_file' }; continue }
- try {
- if ($fileBytes.Length -eq 0 -or $fileBytes.Length -gt 10MB) { Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_file_size' }; continue }
- $fileHash = Get-Sha256Hex -Bytes $fileBytes
- $submissionId = Get-Sha256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes("$projectId|$recordVersion|$fileHash"))
- # 旧版 Windows/.NET 对 multipart 文件名中的非 ASCII 字符存在兼容性问题;群内使用稳定 ASCII 名称,文件正文仍保留原始中文内容。
- $displayFileName = ('{0}-framework-feedback-{1}.md' -f $projectId, $recordVersion)
- $displayFileName = $displayFileName -replace '[^A-Za-z0-9._-]', '_'
- if ([string]::IsNullOrWhiteSpace($displayFileName)) { throw 'Generated display file name is empty.' }
- if (Test-AlreadySent -SubmissionId $submissionId -Path $AuditPath) {
- Write-HttpJson -Context $context -StatusCode 200 -Body @{ status = 'already_sent'; submission_id = $submissionId; file_hash = $fileHash }
- continue
- }
- $result = Invoke-FeishuFileSend -Config $config -FileBytes $fileBytes -FileName $displayFileName
- $entry = @{ submission_id = $submissionId; project_id = $projectId; record_version = $recordVersion; trigger = $trigger; file_hash = $fileHash; message_id = [string]$result.data.message_id; sent_at = (Get-Date).ToString('o') }
- Add-AuditEntry -Entry $entry -Path $AuditPath
- Write-HttpJson -Context $context -StatusCode 200 -Body @{ status = 'sent'; submission_id = $submissionId; file_hash = $fileHash; message_id = [string]$result.data.message_id; sent_at = $entry.sent_at }
- }
- finally { if ($fileBytes) { [Array]::Clear($fileBytes, 0, $fileBytes.Length) } }
- }
- catch {
- Write-Host "Request failed: $($_.Exception.GetType().Name)" -ForegroundColor Yellow
- if (-not $context.Response.OutputStream.CanWrite) { continue }
- Write-HttpJson -Context $context -StatusCode 500 -Body @{ status = 'not_sent'; category = 'server_or_feishu_error' }
- }
- }
- }
- finally {
- $listener.Stop()
- $listener.Close()
- $config.app_secret = $null
- }
|