[AI] Route AI calls through the Xray/V2Ray proxy (reach OpenAI from Iran)
CI/CD / CI · dotnet build (push) Successful in 1m46s
CI/CD / Deploy · hamkadr (push) Failing after 1m58s

Add AiUseProxy setting + a toggle in the AI settings section. ScrapeHttpClients.ForAi(settings) returns a proxied HttpClient (reusing IngestProxyUrl, 100s timeout) when AiUseProxy is on, otherwise direct; AI-cache keys are protected from the scrape-client cleanup. OpenAiCompatibleAuditor now uses it, so the AI auditor (e.g. api.openai.com) is reachable through the same Xray sidecar that serves Telegram. Migration adds the column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 22:55:07 +03:30
parent 018c0f0286
commit 0c49b89891
9 changed files with 1392 additions and 7 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class AiUseProxy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AiUseProxy",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AiUseProxy",
table: "AppSettings");
}
}
}
@@ -53,6 +53,9 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(4000) .HasMaxLength(4000)
.HasColumnType("character varying(4000)"); .HasColumnType("character varying(4000)");
b.Property<bool>("AiUseProxy")
.HasColumnType("boolean");
b.Property<bool>("AutoIngestEnabled") b.Property<bool>("AutoIngestEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
+4
View File
@@ -32,6 +32,10 @@ public class AppSetting
/// <summary>If AI approves AND Mode is Automatic, publish without human review.</summary> /// <summary>If AI approves AND Mode is Automatic, publish without human review.</summary>
public bool AiAutoApprove { get; set; } = false; public bool AiAutoApprove { get; set; } = false;
/// <summary>Route AI calls through the ingestion proxy (IngestProxyUrl) — needed when the AI
/// endpoint (e.g. api.openai.com) is blocked in Iran.</summary>
public bool AiUseProxy { get; set; } = false;
// --- Channel scraping sources (configured here, NOT in env) --- // --- Channel scraping sources (configured here, NOT in env) ---
/// <summary>Run the ingestion worker on a timer.</summary> /// <summary>Run the ingestion worker on a timer.</summary>
public bool AutoIngestEnabled { get; set; } = false; public bool AutoIngestEnabled { get; set; } = false;
@@ -73,6 +73,11 @@
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" /> <input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
<span class="t-body"><span>در حالت خودکار، آگهی‌هایی که AI تأیید می‌کند مستقیم منتشر شوند</span></span> <span class="t-body"><span>در حالت خودکار، آگهی‌هایی که AI تأیید می‌کند مستقیم منتشر شوند</span></span>
</label> </label>
<label class="toggle-row">
<input type="checkbox" name="AiUseProxy" value="true" checked="@Model.AiUseProxy" />
<span class="t-body"><span>ارسال درخواست هوش مصنوعی از طریق پروکسی</span>
<span class="t-hint">برای دسترسی به سرویس‌هایی مثل OpenAI از داخل ایران؛ از همان آدرس پروکسی تب «منابع جمع‌آوری» استفاده می‌کند.</span></span>
</label>
</section> </section>
<!-- SOURCES --> <!-- SOURCES -->
@@ -29,6 +29,7 @@ public class SettingsModel : PageModel
[BindProperty] public string? AiModel { get; set; } [BindProperty] public string? AiModel { get; set; }
[BindProperty] public string AiSystemPrompt { get; set; } = ""; [BindProperty] public string AiSystemPrompt { get; set; } = "";
[BindProperty] public bool AiAutoApprove { get; set; } [BindProperty] public bool AiAutoApprove { get; set; }
[BindProperty] public bool AiUseProxy { get; set; }
// Channel scraping sources // Channel scraping sources
[BindProperty] public bool AutoIngestEnabled { get; set; } [BindProperty] public bool AutoIngestEnabled { get; set; }
[BindProperty] public int IngestIntervalMinutes { get; set; } = 30; [BindProperty] public int IngestIntervalMinutes { get; set; } = 30;
@@ -76,6 +77,7 @@ public class SettingsModel : PageModel
AiModel = s.AiModel; AiModel = s.AiModel;
AiSystemPrompt = s.AiSystemPrompt; AiSystemPrompt = s.AiSystemPrompt;
AiAutoApprove = s.AiAutoApprove; AiAutoApprove = s.AiAutoApprove;
AiUseProxy = s.AiUseProxy;
AutoIngestEnabled = s.AutoIngestEnabled; AutoIngestEnabled = s.AutoIngestEnabled;
IngestIntervalMinutes = s.IngestIntervalMinutes; IngestIntervalMinutes = s.IngestIntervalMinutes;
TelegramEnabled = s.TelegramEnabled; TelegramEnabled = s.TelegramEnabled;
@@ -120,6 +122,7 @@ public class SettingsModel : PageModel
AiModel = AiModel, AiModel = AiModel,
AiSystemPrompt = AiSystemPrompt, AiSystemPrompt = AiSystemPrompt,
AiAutoApprove = AiAutoApprove, AiAutoApprove = AiAutoApprove,
AiUseProxy = AiUseProxy,
AutoIngestEnabled = AutoIngestEnabled, AutoIngestEnabled = AutoIngestEnabled,
IngestIntervalMinutes = IngestIntervalMinutes, IngestIntervalMinutes = IngestIntervalMinutes,
TelegramEnabled = TelegramEnabled, TelegramEnabled = TelegramEnabled,
@@ -30,12 +30,12 @@ public interface IAiAuditor
/// </summary> /// </summary>
public class OpenAiCompatibleAuditor : IAiAuditor public class OpenAiCompatibleAuditor : IAiAuditor
{ {
private readonly IHttpClientFactory _http; private readonly ScrapeHttpClients _clients;
private readonly ILogger<OpenAiCompatibleAuditor> _log; private readonly ILogger<OpenAiCompatibleAuditor> _log;
public OpenAiCompatibleAuditor(IHttpClientFactory http, ILogger<OpenAiCompatibleAuditor> log) public OpenAiCompatibleAuditor(ScrapeHttpClients clients, ILogger<OpenAiCompatibleAuditor> log)
{ {
_http = http; _clients = clients;
_log = log; _log = log;
} }
@@ -57,8 +57,7 @@ public class OpenAiCompatibleAuditor : IAiAuditor
}, },
}; };
var client = _http.CreateClient("ai"); var client = _clients.ForAi(s); // proxy-aware when AiUseProxy is on (e.g. OpenAI from Iran)
client.Timeout = TimeSpan.FromSeconds(30);
using var req = new HttpRequestMessage(HttpMethod.Post, s.AiEndpoint) using var req = new HttpRequestMessage(HttpMethod.Post, s.AiEndpoint)
{ {
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
@@ -27,14 +27,29 @@ public sealed class ScrapeHttpClients : IDisposable
? s.IngestProxyUrl.Trim() ? s.IngestProxyUrl.Trim()
: "direct"; : "direct";
// Drop stale clients if the proxy URL changed (keep only "direct" + the current proxy). // Drop stale clients if the proxy URL changed (keep "direct", current proxy, and AI clients).
foreach (var k in _cache.Keys) foreach (var k in _cache.Keys)
if (k != "direct" && k != key && _cache.TryRemove(k, out var stale)) if (k != "direct" && k != key && !k.StartsWith("ai:") && _cache.TryRemove(k, out var stale))
stale.Dispose(); stale.Dispose();
return _cache.GetOrAdd(key, Build); return _cache.GetOrAdd(key, Build);
} }
/// <summary>HttpClient for AI calls — routed through the proxy when AiUseProxy is on (e.g. to
/// reach api.openai.com from Iran). Longer timeout; cached per proxy URL.</summary>
public HttpClient ForAi(AppSetting s)
{
var useProxy = s.AiUseProxy && !string.IsNullOrWhiteSpace(s.IngestProxyUrl);
var url = useProxy ? s.IngestProxyUrl!.Trim() : null;
var key = "ai:" + (url ?? "direct");
return _cache.GetOrAdd(key, _ =>
{
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
if (url is not null) { handler.Proxy = new WebProxy(url); handler.UseProxy = true; }
return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(100) }; // LLMs can be slow
});
}
private static HttpClient Build(string key) private static HttpClient Build(string key)
{ {
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }; var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
@@ -34,6 +34,7 @@ public class SettingsService
s.AiSystemPrompt = string.IsNullOrWhiteSpace(incoming.AiSystemPrompt) s.AiSystemPrompt = string.IsNullOrWhiteSpace(incoming.AiSystemPrompt)
? AppSetting.DefaultPrompt : incoming.AiSystemPrompt; ? AppSetting.DefaultPrompt : incoming.AiSystemPrompt;
s.AiAutoApprove = incoming.AiAutoApprove; s.AiAutoApprove = incoming.AiAutoApprove;
s.AiUseProxy = incoming.AiUseProxy;
// Channel scraping sources // Channel scraping sources
s.AutoIngestEnabled = incoming.AutoIngestEnabled; s.AutoIngestEnabled = incoming.AutoIngestEnabled;
s.IngestIntervalMinutes = Math.Max(1, incoming.IngestIntervalMinutes); s.IngestIntervalMinutes = Math.Max(1, incoming.IngestIntervalMinutes);