Social auto-posting (phase 1): daily applicant digest to Telegram/Bale + Instagram caption
Adds a «شبکههای اجتماعی» admin section + scheduler that publishes a daily «کادر آمادهبهکار امروز» digest: - AppSetting: social toggles, posts-per-day, editable header/footer, per-channel bot token + chat id (Telegram, Bale), Instagram enable + extra hashtags, proxy toggle, last-posted timestamp (+ migration). - SocialPostService: builds today's talent digest as text, posts to Telegram and Bale via their bot sendMessage APIs (proxy-aware), and produces an Instagram caption + auto hashtags (role/city based). - SocialPostWorker: posts N times/day, evenly spaced, self-paced; reads settings live so it's togglable without redeploy. - /Admin/Social: credentials + header/footer + posts/day, live preview of today's message, «ارسال اکنون» button, and an Instagram caption pack with copy button (semi-automatic — you post the image manually). - Nav link added. Telegram/Bale post as TEXT (per request). The Vazirmatn image card for Instagram is phase 2 (needs SkiaSharp+HarfBuzz + a TTF font). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SocialPosting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "InstagramHashtags",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "character varying(1000)",
|
||||||
|
maxLength: 1000,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SocialBaleBotToken",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SocialBaleChatId",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SocialBaleEnabled",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SocialEnabled",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SocialFooter",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "character varying(1000)",
|
||||||
|
maxLength: 1000,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SocialHeader",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "character varying(1000)",
|
||||||
|
maxLength: 1000,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SocialInstagramEnabled",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "SocialLastPostedAt",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SocialPostsPerDay",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SocialTelegramBotToken",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SocialTelegramChatId",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SocialTelegramEnabled",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SocialUseProxy",
|
||||||
|
table: "AppSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "InstagramHashtags",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialBaleBotToken",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialBaleChatId",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialBaleEnabled",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialEnabled",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialFooter",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialHeader",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialInstagramEnabled",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialLastPostedAt",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialPostsPerDay",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialTelegramBotToken",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialTelegramChatId",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialTelegramEnabled",
|
||||||
|
table: "AppSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SocialUseProxy",
|
||||||
|
table: "AppSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,10 @@ namespace JobsMedical.Web.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("InstagramHashtags")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
b.Property<bool>("MedjobsEnabled")
|
b.Property<bool>("MedjobsEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -133,6 +137,51 @@ namespace JobsMedical.Web.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("SocialBaleBotToken")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SocialBaleChatId")
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<bool>("SocialBaleEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("SocialEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SocialFooter")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("SocialHeader")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<bool>("SocialInstagramEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SocialLastPostedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("SocialPostsPerDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("SocialTelegramBotToken")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SocialTelegramChatId")
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<bool>("SocialTelegramEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("SocialUseProxy")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("TelegramChannels")
|
b.Property<string>("TelegramChannels")
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("character varying(2000)");
|
.HasColumnType("character varying(2000)");
|
||||||
|
|||||||
@@ -104,6 +104,32 @@ public class AppSetting
|
|||||||
[MaxLength(200)] public string? VapidPrivateKey { get; set; }
|
[MaxLength(200)] public string? VapidPrivateKey { get; set; }
|
||||||
[MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir";
|
[MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir";
|
||||||
|
|
||||||
|
// --- Social auto-posting: a daily «کادر آماده به کار» digest to Telegram/Bale (text) + an
|
||||||
|
// Instagram caption/hashtags pack (you post the image manually). ---
|
||||||
|
public bool SocialEnabled { get; set; } = false;
|
||||||
|
/// <summary>How many digests to publish per day (evenly spaced).</summary>
|
||||||
|
public int SocialPostsPerDay { get; set; } = 3;
|
||||||
|
/// <summary>Lines added above/below the auto-generated body (your branding, links, etc.).</summary>
|
||||||
|
[MaxLength(1000)] public string? SocialHeader { get; set; }
|
||||||
|
[MaxLength(1000)] public string? SocialFooter { get; set; }
|
||||||
|
/// <summary>Route the bot calls through the ingestion proxy (Telegram is filtered in Iran).</summary>
|
||||||
|
public bool SocialUseProxy { get; set; } = true;
|
||||||
|
|
||||||
|
public bool SocialTelegramEnabled { get; set; } = false;
|
||||||
|
[MaxLength(200)] public string? SocialTelegramBotToken { get; set; }
|
||||||
|
/// <summary>Channel/chat to post to — «@channelusername» or a numeric chat id.</summary>
|
||||||
|
[MaxLength(120)] public string? SocialTelegramChatId { get; set; }
|
||||||
|
|
||||||
|
public bool SocialBaleEnabled { get; set; } = false;
|
||||||
|
[MaxLength(200)] public string? SocialBaleBotToken { get; set; }
|
||||||
|
[MaxLength(120)] public string? SocialBaleChatId { get; set; }
|
||||||
|
|
||||||
|
public bool SocialInstagramEnabled { get; set; } = false;
|
||||||
|
/// <summary>Extra hashtags appended to the generated Instagram caption (space/line separated).</summary>
|
||||||
|
[MaxLength(1000)] public string? InstagramHashtags { get; set; }
|
||||||
|
|
||||||
|
public DateTime? SocialLastPostedAt { get; set; }
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
|
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@page
|
||||||
|
@model JobsMedical.Web.Pages.Admin.SocialModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "شبکههای اجتماعی";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<h1>شبکههای اجتماعی</h1>
|
||||||
|
<p class="muted">انتشار خودکار «کادر آمادهبهکار امروز» در تلگرام و بله (متن) و بستهی کپشن/هشتگ برای اینستاگرام.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container section">
|
||||||
|
@if (Model.Message is not null) { <div class="alert alert-success">✓ @Model.Message</div> }
|
||||||
|
@if (Model.Error is not null) { <div class="alert alert-error">⚠ @Model.Error</div> }
|
||||||
|
|
||||||
|
<div class="layout-2">
|
||||||
|
<div>
|
||||||
|
<form method="post" class="card card-pad">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" name="SocialEnabled" value="true" checked="@Model.SocialEnabled" />
|
||||||
|
<span class="t-body"><span>انتشار خودکار روشن باشد</span><span class="t-hint">روزانه چند بار، بهصورت زمانبندیشده ارسال میشود.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="filter-group" style="display:flex; gap:8px;">
|
||||||
|
<div style="flex:0 0 160px;"><label>تعداد پست در روز</label><input type="number" name="SocialPostsPerDay" min="1" max="24" value="@Model.SocialPostsPerDay" dir="ltr" /></div>
|
||||||
|
<label class="proxy-toggle" style="align-self:end;"><input type="checkbox" name="SocialUseProxy" value="true" checked="@Model.SocialUseProxy" /> ارسال از طریق پروکسی (برای تلگرام)</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>سرتیتر پیام (Header)</label>
|
||||||
|
<textarea name="SocialHeader" rows="2" placeholder="مثلاً: 🩺 همکادر | مرجع شیفت و استخدام کادر درمان">@Model.SocialHeader</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>پاورقی پیام (Footer)</label>
|
||||||
|
<textarea name="SocialFooter" rows="2" placeholder="مثلاً: ثبت رایگان آگهی در hamkadr.ir | @@hamkadr">@Model.SocialFooter</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-box">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" name="SocialTelegramEnabled" value="true" checked="@Model.SocialTelegramEnabled" />
|
||||||
|
<span class="t-body"><span>📨 تلگرام (متن)</span><span class="t-hint">با بات تلگرام در کانال شما پست میشود.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="filter-group"><label>توکن بات تلگرام</label><input type="password" name="SocialTelegramBotToken" value="@Model.SocialTelegramBotToken" dir="ltr" placeholder="123456:ABC-..." /></div>
|
||||||
|
<div class="filter-group"><label>شناسه کانال/چت</label><input type="text" name="SocialTelegramChatId" value="@Model.SocialTelegramChatId" dir="ltr" placeholder="@@your_channel یا -100..." />
|
||||||
|
<p class="muted" style="font-size:11px; margin:4px 0 0;">بات باید ادمینِ کانال باشد.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-box">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" name="SocialBaleEnabled" value="true" checked="@Model.SocialBaleEnabled" />
|
||||||
|
<span class="t-body"><span>💬 بله (متن)</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="filter-group"><label>توکن بات بله</label><input type="password" name="SocialBaleBotToken" value="@Model.SocialBaleBotToken" dir="ltr" /></div>
|
||||||
|
<div class="filter-group"><label>شناسه کانال/چت بله</label><input type="text" name="SocialBaleChatId" value="@Model.SocialBaleChatId" dir="ltr" placeholder="@@your_channel یا عدد" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-box">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" name="SocialInstagramEnabled" value="true" checked="@Model.SocialInstagramEnabled" />
|
||||||
|
<span class="t-body"><span>📷 اینستاگرام (نیمهخودکار)</span><span class="t-hint">کپشن و هشتگ آماده میشود؛ تصویر و انتشار را دستی انجام میدهی.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="filter-group"><label>هشتگهای اضافه (با فاصله یا خط جدید)</label>
|
||||||
|
<textarea name="InstagramHashtags" rows="2" dir="ltr" placeholder="#استخدام_پرستار #شیفت_تهران">@Model.InstagramHashtags</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-save">
|
||||||
|
<button type="submit" asp-page-handler="Save" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" style="margin-top:12px;">
|
||||||
|
<button type="submit" asp-page-handler="SendNow" class="btn btn-outline btn-block">📤 ارسال اکنون (تلگرام/بله)</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 style="margin-top:0;">پیشنمایش پیام امروز</h3>
|
||||||
|
@if (Model.Preview is null || Model.Preview.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">امروز هنوز موردِ «آماده به کار» تازهای ثبت نشده است.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted" style="font-size:12px;">@JalaliDate.ToPersianDigits(Model.Preview.Count.ToString()) مورد — همین متن به تلگرام/بله میرود.</p>
|
||||||
|
<pre style="white-space:pre-wrap; font-family:inherit; background:var(--bg); border:1px solid var(--line); border-radius:10px; padding:12px; font-size:13px; margin:0;">@Model.Preview.TelegramText</pre>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.SocialInstagramEnabled && Model.Preview is not null && Model.Preview.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card card-pad" style="margin-top:12px;">
|
||||||
|
<h3 style="margin-top:0;">📷 بستهی اینستاگرام</h3>
|
||||||
|
<label style="font-size:12px; font-weight:700;">کپشن (با هشتگ):</label>
|
||||||
|
<textarea id="igCaption" rows="8" style="width:100%; font-size:12.5px;">@Model.Preview.InstagramCaption</textarea>
|
||||||
|
<button type="button" class="btn btn-outline btn-block" style="margin-top:6px;" onclick="navigator.clipboard.writeText(document.getElementById('igCaption').value); this.textContent='کپی شد ✓';">کپی کپشن</button>
|
||||||
|
<p class="muted" style="font-size:11px; margin:8px 0 0;">تصویر کارت با فونت وزیر در نسخهی بعدی اضافه میشود؛ فعلاً کپشن/هشتگ را کپی کن و در اینستاگرام پست کن.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using JobsMedical.Web.Services;
|
||||||
|
using JobsMedical.Web.Services.Scraping;
|
||||||
|
using JobsMedical.Web.Services.Social;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Pages.Admin;
|
||||||
|
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public class SocialModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly SettingsService _settings;
|
||||||
|
private readonly SocialPostService _social;
|
||||||
|
|
||||||
|
public SocialModel(AppDbContext db, SettingsService settings, SocialPostService social)
|
||||||
|
{
|
||||||
|
_db = db; _settings = settings; _social = social;
|
||||||
|
}
|
||||||
|
|
||||||
|
[TempData] public string? Message { get; set; }
|
||||||
|
[TempData] public string? Error { get; set; }
|
||||||
|
|
||||||
|
public SocialDigest? Preview { get; private set; }
|
||||||
|
|
||||||
|
[BindProperty] public bool SocialEnabled { get; set; }
|
||||||
|
[BindProperty] public int SocialPostsPerDay { get; set; }
|
||||||
|
[BindProperty] public string? SocialHeader { get; set; }
|
||||||
|
[BindProperty] public string? SocialFooter { get; set; }
|
||||||
|
[BindProperty] public bool SocialUseProxy { get; set; }
|
||||||
|
[BindProperty] public bool SocialTelegramEnabled { get; set; }
|
||||||
|
[BindProperty] public string? SocialTelegramBotToken { get; set; }
|
||||||
|
[BindProperty] public string? SocialTelegramChatId { get; set; }
|
||||||
|
[BindProperty] public bool SocialBaleEnabled { get; set; }
|
||||||
|
[BindProperty] public string? SocialBaleBotToken { get; set; }
|
||||||
|
[BindProperty] public string? SocialBaleChatId { get; set; }
|
||||||
|
[BindProperty] public bool SocialInstagramEnabled { get; set; }
|
||||||
|
[BindProperty] public string? InstagramHashtags { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
var s = await _settings.GetAsync();
|
||||||
|
SocialEnabled = s.SocialEnabled;
|
||||||
|
SocialPostsPerDay = s.SocialPostsPerDay;
|
||||||
|
SocialHeader = s.SocialHeader;
|
||||||
|
SocialFooter = s.SocialFooter;
|
||||||
|
SocialUseProxy = s.SocialUseProxy;
|
||||||
|
SocialTelegramEnabled = s.SocialTelegramEnabled;
|
||||||
|
SocialTelegramBotToken = s.SocialTelegramBotToken;
|
||||||
|
SocialTelegramChatId = s.SocialTelegramChatId;
|
||||||
|
SocialBaleEnabled = s.SocialBaleEnabled;
|
||||||
|
SocialBaleBotToken = s.SocialBaleBotToken;
|
||||||
|
SocialBaleChatId = s.SocialBaleChatId;
|
||||||
|
SocialInstagramEnabled = s.SocialInstagramEnabled;
|
||||||
|
InstagramHashtags = s.InstagramHashtags;
|
||||||
|
|
||||||
|
Preview = await _social.BuildDigestAsync(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSaveAsync()
|
||||||
|
{
|
||||||
|
var s = await _settings.GetAsync();
|
||||||
|
s.SocialEnabled = SocialEnabled;
|
||||||
|
s.SocialPostsPerDay = Math.Clamp(SocialPostsPerDay, 1, 24);
|
||||||
|
s.SocialHeader = SocialHeader?.Trim();
|
||||||
|
s.SocialFooter = SocialFooter?.Trim();
|
||||||
|
s.SocialUseProxy = SocialUseProxy;
|
||||||
|
s.SocialTelegramEnabled = SocialTelegramEnabled;
|
||||||
|
s.SocialTelegramBotToken = SocialTelegramBotToken?.Trim();
|
||||||
|
s.SocialTelegramChatId = SocialTelegramChatId?.Trim();
|
||||||
|
s.SocialBaleEnabled = SocialBaleEnabled;
|
||||||
|
s.SocialBaleBotToken = SocialBaleBotToken?.Trim();
|
||||||
|
s.SocialBaleChatId = SocialBaleChatId?.Trim();
|
||||||
|
s.SocialInstagramEnabled = SocialInstagramEnabled;
|
||||||
|
s.InstagramHashtags = InstagramHashtags?.Trim();
|
||||||
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
Message = "تنظیمات شبکههای اجتماعی ذخیره شد.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSendNowAsync()
|
||||||
|
{
|
||||||
|
var r = await _social.PostAsync();
|
||||||
|
if (r.Count == 0) Error = r.Error ?? "موردی برای انتشار نبود.";
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (r.TelegramOk) parts.Add("تلگرام ✓");
|
||||||
|
if (r.BaleOk) parts.Add("بله ✓");
|
||||||
|
Message = parts.Count > 0
|
||||||
|
? $"ارسال شد ({string.Join("، ", parts)}) — {JalaliDate.ToPersianDigits(r.Count.ToString())} مورد."
|
||||||
|
: "هیچ کانالی ارسال نشد؛ توکن/شناسه و فعالبودن را بررسی کن.";
|
||||||
|
if (r.Error is not null && parts.Count == 0) Error = r.Error;
|
||||||
|
}
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
|
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
|
||||||
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</a>
|
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</a>
|
||||||
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>
|
<a class="@(On("/Admin/Broadcast") ? "active" : null)" asp-page="/Admin/Broadcast">📣 اعلان همگانی</a>
|
||||||
|
<a class="@(On("/Admin/Social") ? "active" : null)" asp-page="/Admin/Social">📡 شبکههای اجتماعی</a>
|
||||||
<a class="@(On("/Admin/Settings") ? "active" : null)" asp-page="/Admin/Settings">⚙️ تنظیمات</a>
|
<a class="@(On("/Admin/Settings") ? "active" : null)" asp-page="/Admin/Settings">⚙️ تنظیمات</a>
|
||||||
}
|
}
|
||||||
else if (User.IsInRole("FacilityAdmin"))
|
else if (User.IsInRole("FacilityAdmin"))
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
|||||||
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
|
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
|
||||||
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
|
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
|
||||||
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
|
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
|
||||||
|
builder.Services.AddScoped<JobsMedical.Web.Services.Social.SocialPostService>();
|
||||||
|
builder.Services.AddHostedService<JobsMedical.Web.Services.Social.SocialPostWorker>();
|
||||||
|
|
||||||
// Phone-OTP cookie auth.
|
// Phone-OTP cookie auth.
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using JobsMedical.Web.Services.Scraping;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services.Social;
|
||||||
|
|
||||||
|
/// <summary>Result of building the daily digest — reused by the worker, the admin preview, and Instagram.</summary>
|
||||||
|
public record SocialDigest(int Count, string Body, string TelegramText, string InstagramCaption, string Hashtags);
|
||||||
|
|
||||||
|
public record SocialPostResult(bool TelegramOk, bool BaleOk, int Count, string? Error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes a daily «کادر آماده به کار امروز» digest and posts it as text to Telegram and Bale
|
||||||
|
/// (via their bot APIs). Also produces an Instagram caption + hashtags for the manual flow.
|
||||||
|
/// All credentials/toggles live in <see cref="AppSetting"/> (admin panel, DB-backed).
|
||||||
|
/// </summary>
|
||||||
|
public class SocialPostService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly SettingsService _settings;
|
||||||
|
private readonly ScrapeHttpClients _clients;
|
||||||
|
private readonly ILogger<SocialPostService> _log;
|
||||||
|
|
||||||
|
public SocialPostService(AppDbContext db, SettingsService settings, ScrapeHttpClients clients, ILogger<SocialPostService> log)
|
||||||
|
{
|
||||||
|
_db = db; _settings = settings; _clients = clients; _log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Today's freshly-published «آماده به کار» listings, formatted for sharing.</summary>
|
||||||
|
public async Task<SocialDigest> BuildDigestAsync(AppSetting s, int take = 8, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddHours(-24);
|
||||||
|
var items = await _db.TalentListings
|
||||||
|
.Include(t => t.Role).Include(t => t.City).Include(t => t.District)
|
||||||
|
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= since)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var t in items)
|
||||||
|
{
|
||||||
|
var role = t.Role?.Name ?? "کادر درمان";
|
||||||
|
var city = t.City?.Name ?? "";
|
||||||
|
var area = t.District?.Name ?? t.AreaNote;
|
||||||
|
var exp = t.YearsExperience is int y && y > 0 ? $"، {JalaliDate.ToPersianDigits(y.ToString())} سال سابقه" : "";
|
||||||
|
var loc = string.IsNullOrWhiteSpace(area) ? city : $"{city}، {area}";
|
||||||
|
sb.Append("• ").Append(role).Append(exp);
|
||||||
|
if (!string.IsNullOrWhiteSpace(loc)) sb.Append(" — 📍 ").Append(loc);
|
||||||
|
sb.Append('\n');
|
||||||
|
}
|
||||||
|
var body = sb.ToString().TrimEnd();
|
||||||
|
|
||||||
|
var header = string.IsNullOrWhiteSpace(s.SocialHeader) ? null : s.SocialHeader!.Trim();
|
||||||
|
var footer = string.IsNullOrWhiteSpace(s.SocialFooter) ? null : s.SocialFooter!.Trim();
|
||||||
|
var title = $"🩺 کادر درمان آمادهبهکار امروز ({JalaliDate.ToPersianDigits(items.Count.ToString())} نفر)";
|
||||||
|
|
||||||
|
var tg = new StringBuilder();
|
||||||
|
if (header is not null) tg.Append(header).Append("\n\n");
|
||||||
|
tg.Append(title).Append("\n\n");
|
||||||
|
tg.Append(items.Count == 0 ? "امروز موردی ثبت نشد." : body);
|
||||||
|
if (footer is not null) tg.Append("\n\n").Append(footer);
|
||||||
|
|
||||||
|
var hashtags = BuildHashtags(s, items);
|
||||||
|
var caption = new StringBuilder();
|
||||||
|
if (header is not null) caption.Append(header).Append("\n\n");
|
||||||
|
caption.Append(title).Append("\n\n").Append(items.Count == 0 ? "" : body);
|
||||||
|
if (footer is not null) caption.Append("\n\n").Append(footer);
|
||||||
|
caption.Append("\n\n").Append(hashtags);
|
||||||
|
|
||||||
|
return new SocialDigest(items.Count, body, tg.ToString(), caption.ToString().Trim(), hashtags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildHashtags(AppSetting s, List<TalentListing> items)
|
||||||
|
{
|
||||||
|
var tags = new List<string> { "#همکادر", "#استخدام_کادر_درمان", "#آماده_به_کار", "#پرستار", "#استخدام_پرستار", "#کاریابی_پزشکی" };
|
||||||
|
foreach (var t in items)
|
||||||
|
{
|
||||||
|
if (t.Role?.Name is string r) tags.Add("#" + r.Replace(' ', '_'));
|
||||||
|
if (t.City?.Name is string c) tags.Add("#" + c.Replace(' ', '_'));
|
||||||
|
}
|
||||||
|
foreach (var extra in AppSetting.SplitList(s.InstagramHashtags))
|
||||||
|
tags.Add(extra.StartsWith('#') ? extra : "#" + extra.Replace(' ', '_'));
|
||||||
|
return string.Join(" ", tags.Distinct().Take(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build today's digest and post it to the enabled text channels (Telegram + Bale).</summary>
|
||||||
|
public async Task<SocialPostResult> PostAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var s = await _settings.GetAsync();
|
||||||
|
var digest = await BuildDigestAsync(s, ct: ct);
|
||||||
|
if (digest.Count == 0)
|
||||||
|
return new SocialPostResult(false, false, 0, "موردی برای انتشار امروز نبود.");
|
||||||
|
|
||||||
|
var client = _clients.For(s, s.SocialUseProxy);
|
||||||
|
bool tgOk = false, baleOk = false;
|
||||||
|
string? err = null;
|
||||||
|
|
||||||
|
if (s.SocialTelegramEnabled && !string.IsNullOrWhiteSpace(s.SocialTelegramBotToken) && !string.IsNullOrWhiteSpace(s.SocialTelegramChatId))
|
||||||
|
{
|
||||||
|
var (ok, e) = await SendAsync(client, "https://api.telegram.org", s.SocialTelegramBotToken!, s.SocialTelegramChatId!, digest.TelegramText, ct);
|
||||||
|
tgOk = ok; err ??= e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.SocialBaleEnabled && !string.IsNullOrWhiteSpace(s.SocialBaleBotToken) && !string.IsNullOrWhiteSpace(s.SocialBaleChatId))
|
||||||
|
{
|
||||||
|
var (ok, e) = await SendAsync(client, "https://tapi.bale.ai", s.SocialBaleBotToken!, s.SocialBaleChatId!, digest.TelegramText, ct);
|
||||||
|
baleOk = ok; err ??= e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// record the run so the scheduler paces itself
|
||||||
|
var row = await _db.AppSettings.FirstOrDefaultAsync(x => x.Id == 1, ct);
|
||||||
|
if (row is not null) { row.SocialLastPostedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
return new SocialPostResult(tgOk, baleOk, digest.Count, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Telegram-compatible bot sendMessage (Bale shares the same shape).</summary>
|
||||||
|
private async Task<(bool ok, string? error)> SendAsync(HttpClient client, string apiBase, string token, string chatId, string text, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{apiBase}/bot{token}/sendMessage";
|
||||||
|
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["chat_id"] = chatId,
|
||||||
|
["text"] = text.Length > 4000 ? text[..4000] : text,
|
||||||
|
["disable_web_page_preview"] = "true",
|
||||||
|
});
|
||||||
|
using var resp = await client.PostAsync(url, form, ct);
|
||||||
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
if (resp.IsSuccessStatusCode && body.Contains("\"ok\":true")) return (true, null);
|
||||||
|
_log.LogWarning("Social post to {Base} failed: {Status} {Body}", apiBase, (int)resp.StatusCode, body);
|
||||||
|
return (false, ExtractError(body) ?? $"خطای {(int)resp.StatusCode}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Social post to {Base} errored", apiBase);
|
||||||
|
return (false, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractError(string body)
|
||||||
|
{
|
||||||
|
try { using var d = JsonDocument.Parse(body); return d.RootElement.TryGetProperty("description", out var v) ? v.GetString() : null; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using JobsMedical.Web.Services.Scraping;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services.Social;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Posts the daily «آماده به کار» digest to Telegram/Bale on a schedule — SocialPostsPerDay times
|
||||||
|
/// a day, evenly spaced. Reads settings fresh each cycle so it can be toggled from the admin panel
|
||||||
|
/// without a redeploy. Idle and self-paced; the manual «ارسال اکنون» button uses the same service.
|
||||||
|
/// </summary>
|
||||||
|
public class SocialPostWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly ILogger<SocialPostWorker> _log;
|
||||||
|
|
||||||
|
public SocialPostWorker(IServiceScopeFactory scopes, ILogger<SocialPostWorker> log)
|
||||||
|
{
|
||||||
|
_scopes = scopes; _log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(TimeSpan.FromSeconds(40), stoppingToken); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var settings = await scope.ServiceProvider.GetRequiredService<SettingsService>().GetAsync();
|
||||||
|
|
||||||
|
if (settings.SocialEnabled)
|
||||||
|
{
|
||||||
|
var perDay = Math.Clamp(settings.SocialPostsPerDay, 1, 24);
|
||||||
|
var interval = TimeSpan.FromHours(24.0 / perDay);
|
||||||
|
var due = settings.SocialLastPostedAt is null
|
||||||
|
|| DateTime.UtcNow - settings.SocialLastPostedAt.Value >= interval;
|
||||||
|
if (due)
|
||||||
|
{
|
||||||
|
var result = await scope.ServiceProvider.GetRequiredService<SocialPostService>().PostAsync(stoppingToken);
|
||||||
|
_log.LogInformation("Social digest posted: tg={Tg} bale={Bale} count={Count} err={Err}",
|
||||||
|
result.TelegramOk, result.BaleOk, result.Count, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "Social post cycle failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check fairly often (15 min) so toggles/interval changes take effect; PostAsync itself
|
||||||
|
// is gated by SocialLastPostedAt so we never over-post.
|
||||||
|
try { await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user