Initial commit

This commit is contained in:
Popov_Grigorii
2026-07-15 17:17:03 +03:00
commit cc078a2c8d
94 changed files with 76773 additions and 0 deletions
@@ -0,0 +1,22 @@
using ListmonkIntegration.Models;
using TemplateInfo = ListmonkIntegration.Models.TemplateInfo;
namespace ListmonkIntegration.Services
{
public interface IListmonkService
{
Task<bool> TestConnectionAsync();
Task<int> CreateListAsync(string listName);
Task<int> CreateTemplateAsync(string name, string subject, string body);
Task<int> CreateCampaignAsync(string name, int listId, int templateId, string subject);
Task<bool> StartCampaignAsync(int campaignId);
Task<List<ListInfo>> GetListsAsync();
Task<List<string>> GetListAttributesAsync(int listId);
Task<List<TemplateInfo>> GetTemplatesAsync();
Task<string> GetTemplateBodyAsync(int templateId);
Task<Subscriber?> GetSubscriberByEmailAsync(string email);
Task<bool> UpdateSubscriberAsync(int subscriberId, string email, string name, Dictionary<string, string> attributes, List<int> listIds);
Task<bool> ImportSubscribersAsync(List<int> listIds, List<Subscriber> subscribers);
}
}
@@ -0,0 +1,811 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ListmonkIntegration.Models;
using TemplateInfo = ListmonkIntegration.Models.TemplateInfo;
namespace ListmonkIntegration.Services
{
public class ListmonkService : IListmonkService
{
private readonly HttpClient _httpClient;
private readonly ListmonkSettings _settings;
private readonly ILogger<ListmonkService> _logger;
public ListmonkService(
HttpClient httpClient,
ListmonkSettings settings,
ILogger<ListmonkService> logger)
{
_httpClient = httpClient;
_settings = settings;
_logger = logger;
// Настройка базового адреса
_httpClient.BaseAddress = new Uri(_settings.BaseUrl);
// Listmonk использует Basic Auth с API credentials
// Username: логин пользователя (например, "admin")
// Password: API токен (d1qPRY5jl1LsD1LskI4zWu4ukYWTyNLxjot05CjgRin4nQy3)
var username = _settings.Username;
var password = !string.IsNullOrEmpty(_settings.ApiToken)
? _settings.ApiToken
: _settings.Password;
var authToken = Convert.ToBase64String(
Encoding.ASCII.GetBytes($"{username}:{password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", authToken);
_logger.LogInformation($"Using Basic Auth with username: {username}");
}
public async Task<bool> TestConnectionAsync()
{
try
{
_logger.LogInformation($"Testing connection to Listmonk...");
_logger.LogInformation($"Base URL: {_settings.BaseUrl}");
_logger.LogInformation($"Username: {_settings.Username}");
var response = await _httpClient.GetAsync("/api/config");
_logger.LogInformation($"Response status: {response.StatusCode}");
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Success! Connected to Listmonk");
return true;
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning($"Failed! Status: {response.StatusCode}, Content: {errorContent}");
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Exception during connection test: {ex.Message}");
return false;
}
}
public async Task<int> CreateListAsync(string listName)
{
_logger.LogInformation($"Creating list: {listName}");
// Сначала проверим, нет ли уже списка с таким именем
var existingListId = await FindListIdByNameAsync(listName);
if (existingListId.HasValue)
{
_logger.LogInformation($"List '{listName}' already exists with ID {existingListId.Value}");
return existingListId.Value;
}
var content = new StringContent(
JsonSerializer.Serialize(new
{
name = listName,
type = "private",
optin = "single"
}),
Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync("/api/lists", content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Create list response: {responseBody}");
response.EnsureSuccessStatusCode();
var json = JsonDocument.Parse(responseBody);
// Listmonk возвращает {"data": {"id": 1, ...}}
var listId = json.RootElement
.GetProperty("data")
.GetProperty("id")
.GetInt32();
_logger.LogInformation($"List created with ID: {listId}");
return listId;
}
private async Task<int?> FindListIdByNameAsync(string listName)
{
try
{
var response = await _httpClient.GetAsync("/api/lists");
if (response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Lists response: {responseBody.Substring(0, Math.Min(200, responseBody.Length))}...");
var json = JsonDocument.Parse(responseBody);
// Структура: {"data": {"results": [...]}}
if (json.RootElement.TryGetProperty("data", out var data))
{
JsonElement results;
if (data.ValueKind == JsonValueKind.Object &&
data.TryGetProperty("results", out results))
{
if (results.ValueKind == JsonValueKind.Array)
{
foreach (var list in results.EnumerateArray())
{
var name = list.GetProperty("name").GetString();
if (name == listName)
{
var id = list.GetProperty("id").GetInt32();
_logger.LogInformation($"Found existing list '{listName}' with ID {id}");
return id;
}
}
}
}
else if (data.ValueKind == JsonValueKind.Array)
{
foreach (var list in data.EnumerateArray())
{
var name = list.GetProperty("name").GetString();
if (name == listName)
{
var id = list.GetProperty("id").GetInt32();
return id;
}
}
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Error searching for list '{listName}'");
}
return null;
}
public async Task<bool> ImportSubscribersAsync(List<int> listIds, List<Subscriber> subscribers)
{
_logger.LogInformation($"Importing {subscribers.Count} subscribers to lists: {string.Join(", ", listIds)}");
int successCount = 0;
int failCount = 0;
int updatedCount = 0;
int createdCount = 0;
foreach (var sub in subscribers)
{
try
{
// Проверяем, существует ли подписчик
var existingSubscriber = await GetSubscriberByEmailAsync(sub.Email);
if (existingSubscriber != null)
{
// Подписчик существует - обновляем его
_logger.LogInformation($"Subscriber {sub.Email} exists, updating...");
// Получаем ID существующего подписчика
var subscriberId = await GetSubscriberIdByEmailAsync(sub.Email);
if (subscriberId.HasValue)
{
// Получаем текущие списки подписчика
var currentListIds = await GetSubscriberListIdsAsync(subscriberId.Value);
// Добавляем новые списки к существующим
var allListIds = new List<int>(currentListIds);
foreach (var listId in listIds)
{
if (!allListIds.Contains(listId))
{
allListIds.Add(listId);
}
}
// Объединяем атрибуты
var mergedAttributes = new Dictionary<string, string>(existingSubscriber.Attributes);
foreach (var attr in sub.Attributes)
{
mergedAttributes[attr.Key] = attr.Value;
}
// Обновляем подписчика
var updated = await UpdateSubscriberAsync(
subscriberId.Value,
sub.Email,
sub.FullName,
mergedAttributes,
allListIds
);
if (updated)
{
updatedCount++;
successCount++;
}
else
{
failCount++;
}
}
else
{
failCount++;
}
}
else
{
// Создаём нового подписчика
await AddSubscriberToList(listIds, sub);
createdCount++;
successCount++;
}
}
catch (Exception ex)
{
failCount++;
_logger.LogWarning(ex, $"Failed to process subscriber {sub.Email}");
}
}
_logger.LogInformation($"Import completed: {successCount} success ({createdCount} created, {updatedCount} updated), {failCount} failed");
return true;
}
private async Task<List<int>> GetSubscriberListIdsAsync(int subscriberId)
{
try
{
var response = await _httpClient.GetAsync($"/api/subscribers/{subscriberId}");
if (!response.IsSuccessStatusCode)
return new List<int>();
var responseBody = await response.Content.ReadAsStringAsync();
var json = JsonDocument.Parse(responseBody);
var listIds = new List<int>();
if (json.RootElement.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("lists", out var lists))
{
foreach (var list in lists.EnumerateArray())
{
if (list.TryGetProperty("id", out var id))
{
listIds.Add(id.GetInt32());
}
}
}
}
return listIds;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting list IDs for subscriber {subscriberId}");
return new List<int>();
}
}
private async Task AddSubscriberToList(List<int> listIds, Subscriber subscriber)
{
var subscriberData = new
{
email = subscriber.Email,
name = subscriber.FullName,
lists = listIds, // Массив ID списков
attribs = subscriber.Attributes,
status = "enabled",
mode = "subscribe"
};
var jsonContent = JsonSerializer.Serialize(subscriberData);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("/api/subscribers", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning($"Failed to add subscriber {subscriber.Email}: {response.StatusCode} - {errorContent}");
throw new Exception($"Failed to add subscriber: {errorContent}");
}
}
public async Task<bool> CreateCampaignAsync(string name, int listId, int templateId)
{
var content = new StringContent(
JsonSerializer.Serialize(new
{
name = name,
subject = "Тема письма",
lists = new[] { listId },
template_id = templateId,
type = "regular",
content_type = "html"
}),
Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync("/api/campaigns", content);
return response.IsSuccessStatusCode;
}
public async Task<List<ListInfo>> GetListsAsync()
{
_logger.LogInformation("Fetching lists from Listmonk");
var response = await _httpClient.GetAsync("/api/lists");
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError($"Failed to fetch lists: {response.StatusCode} - {errorContent}");
throw new Exception($"Failed to fetch lists: {response.StatusCode}");
}
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Raw response: {responseBody.Substring(0, Math.Min(500, responseBody.Length))}");
var json = JsonDocument.Parse(responseBody);
var lists = new List<ListInfo>();
// Структура: {"data": {"results": [...]}}
if (json.RootElement.TryGetProperty("data", out var data))
{
if (data.ValueKind == JsonValueKind.Object &&
data.TryGetProperty("results", out var results))
{
if (results.ValueKind == JsonValueKind.Array)
{
foreach (var list in results.EnumerateArray())
{
var listInfo = new ListInfo
{
Id = list.GetProperty("id").GetInt32(),
Name = list.GetProperty("name").GetString() ?? string.Empty,
SubscriberCount = list.TryGetProperty("subscriber_count", out var countProp)
? countProp.GetInt32()
: 0
};
lists.Add(listInfo);
_logger.LogInformation($"Parsed list: ID={listInfo.Id}, Name={listInfo.Name}, Subscribers={listInfo.SubscriberCount}");
}
}
}
else if (data.ValueKind == JsonValueKind.Array)
{
// На случай, если Listmonk вернёт массив напрямую
foreach (var list in data.EnumerateArray())
{
var listInfo = new ListInfo
{
Id = list.GetProperty("id").GetInt32(),
Name = list.GetProperty("name").GetString() ?? string.Empty,
SubscriberCount = list.TryGetProperty("subscriber_count", out var countProp)
? countProp.GetInt32()
: 0
};
lists.Add(listInfo);
}
}
}
_logger.LogInformation($"Total lists found: {lists.Count}");
return lists;
}
public async Task<List<string>> GetListAttributesAsync(int listId)
{
_logger.LogInformation($"Fetching attributes for list {listId}");
// Получаем ТОЛЬКО подписчиков из конкретного списка
var response = await _httpClient.GetAsync($"/api/subscribers?list_id={listId}&per_page=1000");
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning($"Failed to fetch subscribers for list {listId}: {response.StatusCode}");
return new List<string>();
}
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Subscribers response for list {listId}: {responseBody.Substring(0, Math.Min(500, responseBody.Length))}");
var json = JsonDocument.Parse(responseBody);
var attributes = new HashSet<string>();
// Структура: {"data": {"results": [...]}}
if (json.RootElement.TryGetProperty("data", out var data))
{
JsonElement results;
if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results))
{
if (results.ValueKind == JsonValueKind.Array)
{
foreach (var subscriber in results.EnumerateArray())
{
if (subscriber.TryGetProperty("attribs", out var attribs))
{
foreach (var prop in attribs.EnumerateObject())
{
attributes.Add(prop.Name);
_logger.LogDebug($"Found attribute '{prop.Name}' for list {listId}");
}
}
}
}
}
else if (data.ValueKind == JsonValueKind.Array)
{
foreach (var subscriber in data.EnumerateArray())
{
if (subscriber.TryGetProperty("attribs", out var attribs))
{
foreach (var prop in attribs.EnumerateObject())
{
attributes.Add(prop.Name);
}
}
}
}
}
var result = attributes.ToList();
_logger.LogInformation($"Found {result.Count} unique attributes for list {listId}: {string.Join(", ", result)}");
return result;
}
public async Task<int> CreateTemplateAsync(string name, string subject, string body)
{
_logger.LogInformation($"Creating template: {name}");
// Listmonk требует плейсхолдер {{ template "content" . }} в теле шаблона
// Если его нет — добавляем автоматически
if (!body.Contains("{{ template \"content\" . }}"))
{
body = body.TrimEnd() + "\n\n{{ template \"content\" . }}";
_logger.LogInformation("Added required placeholder to template body");
}
var templateData = new
{
name = name,
subject = subject,
body = body,
type = "campaign"
};
var jsonContent = JsonSerializer.Serialize(templateData);
_logger.LogInformation($"Request body: {jsonContent}");
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("/api/templates", content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Response status: {response.StatusCode}");
_logger.LogInformation($"Response body: {responseBody}");
if (!response.IsSuccessStatusCode)
{
_logger.LogError($"Failed to create template: {response.StatusCode} - {responseBody}");
throw new Exception($"Failed to create template: {responseBody}");
}
var json = JsonDocument.Parse(responseBody);
int templateId;
if (json.RootElement.TryGetProperty("data", out var data))
{
templateId = data.GetProperty("id").GetInt32();
}
else
{
templateId = json.RootElement.GetProperty("id").GetInt32();
}
_logger.LogInformation($"Template created with ID: {templateId}");
return templateId;
}
public async Task<List<TemplateInfo>> GetTemplatesAsync()
{
_logger.LogInformation("Fetching templates from Listmonk");
var response = await _httpClient.GetAsync("/api/templates");
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
_logger.LogError($"Failed to fetch templates: {response.StatusCode} - {error}");
return new List<TemplateInfo>();
}
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Templates response: {responseBody.Substring(0, Math.Min(500, responseBody.Length))}");
var json = JsonDocument.Parse(responseBody);
var templates = new List<TemplateInfo>();
if (json.RootElement.TryGetProperty("data", out var data))
{
JsonElement results;
if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results))
{
if (results.ValueKind == JsonValueKind.Array)
{
foreach (var template in results.EnumerateArray())
{
templates.Add(new TemplateInfo
{
Id = template.GetProperty("id").GetInt32(),
Name = template.GetProperty("name").GetString() ?? string.Empty,
Subject = template.TryGetProperty("subject", out var subj)
? subj.GetString() ?? string.Empty
: string.Empty
});
}
}
}
else if (data.ValueKind == JsonValueKind.Array)
{
foreach (var template in data.EnumerateArray())
{
templates.Add(new TemplateInfo
{
Id = template.GetProperty("id").GetInt32(),
Name = template.GetProperty("name").GetString() ?? string.Empty,
Subject = template.TryGetProperty("subject", out var subj)
? subj.GetString() ?? string.Empty
: string.Empty
});
}
}
}
_logger.LogInformation($"Found {templates.Count} templates");
return templates;
}
public async Task<string> GetTemplateBodyAsync(int templateId)
{
_logger.LogInformation($"Fetching template {templateId}");
var response = await _httpClient.GetAsync($"/api/templates/{templateId}");
response.EnsureSuccessStatusCode();
var responseBody = await response.Content.ReadAsStringAsync();
var json = JsonDocument.Parse(responseBody);
if (json.RootElement.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("body", out var body))
{
return body.GetString() ?? string.Empty;
}
}
return string.Empty;
}
public async Task<int> CreateCampaignAsync(string name, int listId, int templateId, string subject)
{
_logger.LogInformation($"Creating campaign: {name}");
_logger.LogInformation($"List ID: {listId}, Template ID: {templateId}, Subject: {subject}");
// Убираем send_at - кампания создастся как черновик, потом запустим отдельно
var campaignData = new
{
name = name,
subject = subject,
lists = new[] { listId },
template_id = templateId,
type = "regular",
content_type = "html",
messenger = "email"
};
var jsonContent = JsonSerializer.Serialize(campaignData);
_logger.LogInformation($"Request body: {jsonContent}");
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("/api/campaigns", content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Response status: {response.StatusCode}");
_logger.LogInformation($"Response body: {responseBody}");
if (!response.IsSuccessStatusCode)
{
_logger.LogError($"Failed to create campaign: {response.StatusCode} - {responseBody}");
throw new Exception($"Failed to create campaign: {responseBody}");
}
var json = JsonDocument.Parse(responseBody);
int campaignId;
if (json.RootElement.TryGetProperty("data", out var data))
{
campaignId = data.GetProperty("id").GetInt32();
}
else
{
campaignId = json.RootElement.GetProperty("id").GetInt32();
}
_logger.LogInformation($"Campaign created with ID: {campaignId}");
return campaignId;
}
public async Task<bool> StartCampaignAsync(int campaignId)
{
_logger.LogInformation($"Starting campaign {campaignId}");
var statusData = new
{
status = "running"
};
var jsonContent = JsonSerializer.Serialize(statusData);
_logger.LogInformation($"Request body: {jsonContent}");
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
// ВАЖНО: PUT, не POST!
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/campaigns/{campaignId}/status")
{
Content = content
};
var response = await _httpClient.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Response status: {response.StatusCode}");
_logger.LogInformation($"Response body: {responseBody}");
if (!response.IsSuccessStatusCode)
{
_logger.LogError($"Failed to start campaign: {response.StatusCode} - {responseBody}");
return false;
}
_logger.LogInformation($"Campaign {campaignId} started successfully");
return true;
}
public async Task<Subscriber?> GetSubscriberByEmailAsync(string email)
{
_logger.LogInformation($"Searching for subscriber by email: {email}");
try
{
// Ищем подписчика по email
var response = await _httpClient.GetAsync($"/api/subscribers?query=LOWER(email)=LOWER('{email}')&per_page=1");
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning($"Failed to search subscriber: {response.StatusCode}");
return null;
}
var responseBody = await response.Content.ReadAsStringAsync();
var json = JsonDocument.Parse(responseBody);
if (json.RootElement.TryGetProperty("data", out var data))
{
JsonElement results;
if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results))
{
if (results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
{
var subscriberData = results[0];
// Парсим атрибуты
var attributes = new Dictionary<string, string>();
if (subscriberData.TryGetProperty("attribs", out var attribs))
{
foreach (var prop in attribs.EnumerateObject())
{
attributes[prop.Name] = prop.Value.GetString() ?? string.Empty;
}
}
// Получаем списки
var listIds = new List<int>();
if (subscriberData.TryGetProperty("lists", out var lists))
{
foreach (var list in lists.EnumerateArray())
{
if (list.TryGetProperty("id", out var id))
{
listIds.Add(id.GetInt32());
}
}
}
return new Subscriber
{
Email = subscriberData.GetProperty("email").GetString() ?? string.Empty,
FullName = subscriberData.GetProperty("name").GetString() ?? string.Empty,
Attributes = attributes
};
}
}
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error searching subscriber {email}");
return null;
}
}
public async Task<bool> UpdateSubscriberAsync(int subscriberId, string email, string name, Dictionary<string, string> attributes, List<int> listIds)
{
_logger.LogInformation($"Updating subscriber {subscriberId} ({email})");
var updateData = new
{
email = email,
name = name,
attribs = attributes,
lists = listIds,
status = "enabled"
};
var jsonContent = JsonSerializer.Serialize(updateData);
_logger.LogInformation($"Update data: {jsonContent}");
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await _httpClient.PutAsync($"/api/subscribers/{subscriberId}", content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"Update response: {response.StatusCode} - {responseBody}");
return response.IsSuccessStatusCode;
}
private async Task<int?> GetSubscriberIdByEmailAsync(string email)
{
try
{
var response = await _httpClient.GetAsync($"/api/subscribers?query=LOWER(email)=LOWER('{email}')&per_page=1");
if (!response.IsSuccessStatusCode)
return null;
var responseBody = await response.Content.ReadAsStringAsync();
var json = JsonDocument.Parse(responseBody);
if (json.RootElement.TryGetProperty("data", out var data))
{
JsonElement results;
if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("results", out results))
{
if (results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
{
return results[0].GetProperty("id").GetInt32();
}
}
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting subscriber ID for {email}");
return null;
}
}
}
}