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:
@@ -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