Compare commits

...

6 commits
5.3.8 ... v5

14 changed files with 231 additions and 94 deletions

View file

@ -2,6 +2,25 @@
Mostly based on [keepachangelog](https://keepachangelog.com/en/1.1.0/) except date format. a-c-f-r-o Mostly based on [keepachangelog](https://keepachangelog.com/en/1.1.0/) except date format. a-c-f-r-o
## [5.3.9] - 31.01.2025
## Added
- Added `.todo archive done <name>`
- Creates an archive of only currently completed todos
- An alternative to ".todo archive add <name>" which moves all todos to an archive
## Changed
- Increased todo and archive limits slightly
- Global ellie captcha patron ad will show 12.5% of the time now, down from 20%, and be smaller
- `.remind` now has a 1 year max timeout, up from 2 months
## Fixed
- Captcha is now slightly bigger, with larger margin, to mitigate phone edge issues
- Fixed `.stock` command, unless there is some ip blocking going on
## [5.3.8] - 29.01.2025 ## [5.3.8] - 29.01.2025
## Fixed ## Fixed
@ -195,7 +214,7 @@ Mostly based on [keepachangelog](https://keepachangelog.com/en/1.1.0/) except da
- Self Assigned Roles reworked! Use `.h .sar` for the list of commands - Self Assigned Roles reworked! Use `.h .sar` for the list of commands
- `.sar autodel` - `.sar autodel`
- Toggles the automatic deletion of the user's message and Nadeko's confirmations for .iam and .iamn commands. - Toggles the automatic deletion of the user's message and Ellie's confirmations for .iam and .iamn commands.
- `.sar ad` - `.sar ad`
- Adds a role to the list of self-assignable roles. You can also specify a group. - Adds a role to the list of self-assignable roles. You can also specify a group.
- If 'Exclusive self-assignable roles' feature is enabled (.sar exclusive), users will be able to pick one role - If 'Exclusive self-assignable roles' feature is enabled (.sar exclusive), users will be able to pick one role

View file

@ -4,7 +4,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings> <ImplicitUsings>true</ImplicitUsings>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<Version>5.3.8</Version> <Version>5.3.9</Version>
<!-- Output/build --> <!-- Output/build -->
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory> <RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>

View file

@ -162,15 +162,15 @@ public partial class Gambling : GamblingModule<GamblingService>
if (password is not null) if (password is not null)
{ {
var img = GetPasswordImage(password); var img = _captchaService.GetPasswordImage(password);
await using var stream = await img.ToStreamAsync(); await using var stream = await img.ToStreamAsync();
var toSend = Response() var toSend = Response()
.File(stream, "timely.png"); .File(stream, "timely.png");
#if GLOBAL_ELLIE #if GLOBAL_ELLIE
if (_rng.Next(0, 5) == 0) if (_rng.Next(0, 8) == 0)
toSend = toSend toSend = toSend
.Confirm("[Sub on Patreon](https://patreon.com/elliebot) to remove captcha."); .Text("*[Sub on Patreon](https://patreon.com/elliebot) to remove captcha.*");
#endif #endif
var captchaMessage = await toSend.SendAsync(); var captchaMessage = await toSend.SendAsync();
@ -194,39 +194,6 @@ public partial class Gambling : GamblingModule<GamblingService>
await ClaimTimely(); await ClaimTimely();
} }
private Image<Rgba32> GetPasswordImage(string password)
{
var img = new Image<Rgba32>(50, 24);
var font = _fonts.NotoSans.CreateFont(22);
var outlinePen = new SolidPen(Color.Black, 0.5f);
var strikeoutRun = new RichTextRun
{
Start = 0,
End = password.GetGraphemeCount(),
Font = font,
StrikeoutPen = new SolidPen(Color.White, 4),
TextDecorations = TextDecorations.Strikeout
};
// draw password on the image
img.Mutate(x =>
{
x.DrawText(new RichTextOptions(font)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
FallbackFontFamilies = _fonts.FallBackFonts,
Origin = new(25, 12),
TextRuns = [strikeoutRun]
},
password,
Brushes.Solid(Color.White),
outlinePen);
});
return img;
}
private async Task ClaimTimely() private async Task ClaimTimely()
{ {
var period = Config.Timely.Cooldown; var period = Config.Timely.Cooldown;

View file

@ -16,7 +16,7 @@ public sealed class CaptchaService(FontProvider fonts, IBotCache cache, IPatrona
public Image<Rgba32> GetPasswordImage(string password) public Image<Rgba32> GetPasswordImage(string password)
{ {
var img = new Image<Rgba32>(50, 24); var img = new Image<Rgba32>(60, 34);
var font = fonts.NotoSans.CreateFont(22); var font = fonts.NotoSans.CreateFont(22);
var outlinePen = new SolidPen(Color.Black, 0.5f); var outlinePen = new SolidPen(Color.Black, 0.5f);
@ -38,7 +38,7 @@ public sealed class CaptchaService(FontProvider fonts, IBotCache cache, IPatrona
HorizontalAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
FallbackFontFamilies = fonts.FallBackFonts, FallbackFontFamilies = fonts.FallBackFonts,
Origin = new(25, 12), Origin = new(30, 15),
TextRuns = [strikeoutRun] TextRuns = [strikeoutRun]
}, },
password, password,

View file

@ -33,9 +33,9 @@ public partial class Games
.File(stream, "timely.png"); .File(stream, "timely.png");
#if GLOBAL_ELLIE #if GLOBAL_ELLIE
if (_rng.Next(0, 5) == 0) if (_rng.Next(0, 8) == 0)
toSend = toSend toSend = toSend
.Confirm("[Sub on Patreon](https://patreon.com/elliebot) to remove captcha."); .Text("*[Sub on Patreon](https://patreon.com/elliebot) to remove captcha.*");
#endif #endif
var captcha = await toSend.SendAsync(); var captcha = await toSend.SendAsync();

View file

@ -2,6 +2,8 @@
using CsvHelper; using CsvHelper;
using CsvHelper.Configuration; using CsvHelper.Configuration;
using System.Globalization; using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
namespace EllieBot.Modules.Searches; namespace EllieBot.Modules.Searches;
@ -9,54 +11,57 @@ namespace EllieBot.Modules.Searches;
public sealed class DefaultStockDataService : IStockDataService, IEService public sealed class DefaultStockDataService : IStockDataService, IEService
{ {
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IBotCache _cache;
public DefaultStockDataService(IHttpClientFactory httpClientFactory) public DefaultStockDataService(IHttpClientFactory httpClientFactory, IBotCache cache)
=> _httpClientFactory = httpClientFactory; => (_httpClientFactory, _cache) = (httpClientFactory, cache);
private static TypedKey<StockData> GetStockDataKey(string query)
=> new($"stockdata:{query}");
public async Task<StockData?> GetStockDataAsync(string query) public async Task<StockData?> GetStockDataAsync(string query)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
return await _cache.GetOrAddAsync(GetStockDataKey(query.Trim().ToLowerInvariant()),
() => GetStockDataInternalAsync(query),
expiry: TimeSpan.FromHours(1));
}
public async Task<StockData?> GetStockDataInternalAsync(string query)
{ {
try try
{ {
if (!query.IsAlphaNumeric()) if (!query.IsAlphaNumeric())
return default; return default;
using var http = _httpClientFactory.CreateClient(); var info = await GetNasdaqDataResponse<NasdaqSummaryResponse>(
$"https://api.nasdaq.com/api/quote/{query}/summary?assetclass=stocks");
var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}"; if (info?.Data is not { } d || d.SummaryData is not { } sd)
var config = Configuration.Default.WithDefaultLoader();
using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage);
var tickerName = document.QuerySelector("div.top > .left > .container > h1")
?.TextContent;
if (tickerName is null)
return default; return default;
var marketcap = document var closePrice = double.Parse(sd.PreviousClose.Value?.Substring(1) ?? "0",
.QuerySelector("li > span > fin-streamer[data-field='marketCap']") NumberStyles.Any,
?.TextContent; CultureInfo.InvariantCulture);
var price = d.BidAsk.Bid.Value.IndexOf('*') is var idx and > 0
var volume = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketVolume']") && double.TryParse(d.BidAsk.Bid.Value.Substring(1, idx - 1),
?.TextContent; NumberStyles.Any,
CultureInfo.InvariantCulture,
var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']") out var bid)
?.TextContent ? bid
?? "0"; : double.NaN;
var price = document.QuerySelector("fin-streamer.livePrice > span")
?.TextContent
?? "0";
return new() return new()
{ {
Name = tickerName, Name = query,
Symbol = query, Symbol = info.Data.Symbol,
Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture), Price = price,
Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture), Close = closePrice,
MarketCap = marketcap, MarketCap = sd.MarketCap.Value,
DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture), DailyVolume =
(long)double.Parse(sd.AverageVolume.Value ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture),
}; };
} }
catch (Exception ex) catch (Exception ex)
@ -66,6 +71,36 @@ public sealed class DefaultStockDataService : IStockDataService, IEService
} }
} }
private async Task<NasdaqDataResponse<T>?> GetNasdaqDataResponse<T>(string url)
{
using var httpClient = _httpClientFactory.CreateClient("google:search");
var req = new HttpRequestMessage(HttpMethod.Get,
url)
{
Headers =
{
{ "Host", "api.nasdaq.com" },
{ "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0" },
{ "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
{ "Accept-Language", "en-US,en;q=0.5" },
{ "Accept-Encoding", "gzip, deflate, br, zstd" },
{ "Connection", "keep-alive" },
{ "Upgrade-Insecure-Requests", "1" },
{ "Sec-Fetch-Dest", "document" },
{ "Sec-Fetch-Mode", "navigate" },
{ "Sec-Fetch-Site", "none" },
{ "Sec-Fetch-User", "?1" },
{ "Priority", "u=0, i" },
{ "TE", "trailers" }
}
};
var res = await httpClient.SendAsync(req);
var info = await res.Content.ReadFromJsonAsync<NasdaqDataResponse<T>>();
return info;
}
public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query) public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
@ -91,22 +126,37 @@ public sealed class DefaultStockDataService : IStockDataService, IEService
.ToList(); .ToList();
} }
private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture); private static TypedKey<IReadOnlyCollection<CandleData>> GetCandleDataKey(string query)
=> new($"candledata:{query}");
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query) public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
=> await _cache.GetOrAddAsync(GetCandleDataKey(query),
async () => await GetCandleDataInternalAsync(query),
expiry: TimeSpan.FromHours(4))
?? [];
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataInternalAsync(string query)
{ {
using var http = _httpClientFactory.CreateClient(); using var http = _httpClientFactory.CreateClient();
await using var resStream = await http.GetStreamAsync(
$"https://query1.finance.yahoo.com/v7/finance/download/{query}"
+ $"?period1={DateTime.UtcNow.Subtract(30.Days()).ToTimestamp()}"
+ $"&period2={DateTime.UtcNow.ToTimestamp()}"
+ "&interval=1d");
using var textReader = new StreamReader(resStream); var now = DateTime.UtcNow;
using var csv = new CsvReader(textReader, _csvConfig); var fromdate = now.Subtract(30.Days()).ToString("yyyy-MM-dd");
var records = csv.GetRecords<YahooFinanceCandleData>().ToArray(); var todate = now.ToString("yyyy-MM-dd");
return records var res = await GetNasdaqDataResponse<NasdaqChartResponse>(
.Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume)); $"https://api.nasdaq.com/api/quote/{query}/chart?assetclass=stocks"
+ $"&fromdate={fromdate}"
+ $"&todate={todate}");
if (res?.Data?.Chart is not { } chart)
return Array.Empty<CandleData>();
return chart.Select(d => new CandleData(d.Z.Open,
d.Z.Close,
d.Z.High,
d.Z.Low,
(long)double.Parse(d.Z.Volume, NumberStyles.Any, CultureInfo.InvariantCulture)))
.ToList();
} }
} }

View file

@ -0,0 +1,20 @@
namespace EllieBot.Modules.Searches;
public sealed class NasdaqChartResponse
{
public required NasdaqChartResponseData[] Chart { get; init; }
public sealed class NasdaqChartResponseData
{
public required CandleData Z { get; init; }
public sealed class CandleData
{
public required decimal High { get; init; }
public required decimal Low { get; init; }
public required decimal Open { get; init; }
public required decimal Close { get; init; }
public required string Volume { get; init; }
}
}
}

View file

@ -0,0 +1,6 @@
namespace EllieBot.Modules.Searches;
public sealed class NasdaqDataResponse<T>
{
public required T? Data { get; init; }
}

View file

@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace EllieBot.Modules.Searches;
public sealed class NasdaqSummaryResponse
{
public required string Symbol { get; init; }
public required NasdaqSummaryResponseData SummaryData { get; init; }
public required NasdaqSummaryBidAsk BidAsk { get; init; }
public sealed class NasdaqSummaryBidAsk
{
[JsonPropertyName("Bid * Size")]
public required NasdaqBid Bid { get; init; }
public sealed class NasdaqBid
{
public required string Value { get; init; }
}
}
public sealed class NasdaqSummaryResponseData
{
public required PreviousCloseData PreviousClose { get; init; }
public required MarketCapData MarketCap { get; init; }
public required AverageVolumeData AverageVolume { get; init; }
public sealed class PreviousCloseData
{
public required string Value { get; init; }
}
public sealed class MarketCapData
{
public required string Value { get; init; }
}
public sealed class AverageVolumeData
{
public required string Value { get; init; }
}
}
}

View file

@ -183,7 +183,7 @@ public partial class Utility
{ {
var time = DateTime.UtcNow + ts; var time = DateTime.UtcNow + ts;
if (ts > TimeSpan.FromDays(60)) if (ts > TimeSpan.FromDays(366))
return false; return false;
if (ctx.Guild is not null) if (ctx.Guild is not null)

View file

@ -150,7 +150,26 @@ public partial class Utility
[Cmd] [Cmd]
public async Task TodoArchiveAdd([Leftover] string name) public async Task TodoArchiveAdd([Leftover] string name)
{ {
var result = await _service.ArchiveTodosAsync(ctx.User.Id, name); var result = await _service.ArchiveTodosAsync(ctx.User.Id, name, false);
if (result == ArchiveTodoResult.NoTodos)
{
await Response().Error(strs.todo_no_todos).SendAsync();
return;
}
if (result == ArchiveTodoResult.MaxLimitReached)
{
await Response().Error(strs.todo_archive_max_limit).SendAsync();
return;
}
await ctx.OkAsync();
}
[Cmd]
public async Task TodoArchiveDone([Leftover] string name)
{
var result = await _service.ArchiveTodosAsync(ctx.User.Id, name, true);
if (result == ArchiveTodoResult.NoTodos) if (result == ArchiveTodoResult.NoTodos)
{ {
await Response().Error(strs.todo_no_todos).SendAsync(); await Response().Error(strs.todo_no_todos).SendAsync();
@ -193,7 +212,7 @@ public partial class Utility
foreach (var archivedList in items) foreach (var archivedList in items)
{ {
eb.AddField($"id: {archivedList.Id.ToString()}", archivedList.Name, true); eb.AddField($"id: {new kwum(archivedList.Id)}", archivedList.Name, true);
} }
return eb; return eb;
@ -202,7 +221,7 @@ public partial class Utility
} }
[Cmd] [Cmd]
public async Task TodoArchiveShow(int id) public async Task TodoArchiveShow(kwum id)
{ {
var list = await _service.GetArchivedTodoListAsync(ctx.User.Id, id); var list = await _service.GetArchivedTodoListAsync(ctx.User.Id, id);
if (list == null || list.Items.Count == 0) if (list == null || list.Items.Count == 0)
@ -234,7 +253,7 @@ public partial class Utility
} }
[Cmd] [Cmd]
public async Task TodoArchiveDelete(int id) public async Task TodoArchiveDelete(kwum id)
{ {
if (!await _service.ArchiveDeleteAsync(ctx.User.Id, id)) if (!await _service.ArchiveDeleteAsync(ctx.User.Id, id))
{ {

View file

@ -6,8 +6,8 @@ namespace EllieBot.Modules.Utility;
public sealed class TodoService : IEService public sealed class TodoService : IEService
{ {
private const int ARCHIVE_MAX_COUNT = 9; private const int ARCHIVE_MAX_COUNT = 18;
private const int TODO_MAX_COUNT = 27; private const int TODO_MAX_COUNT = 36;
private readonly DbService _db; private readonly DbService _db;
@ -111,7 +111,7 @@ public sealed class TodoService : IEService
.DeleteAsync(); .DeleteAsync();
} }
public async Task<ArchiveTodoResult> ArchiveTodosAsync(ulong userId, string name) public async Task<ArchiveTodoResult> ArchiveTodosAsync(ulong userId, string name, bool onlyDone)
{ {
// create a new archive // create a new archive
@ -140,7 +140,7 @@ public sealed class TodoService : IEService
var updated = await ctx var updated = await ctx
.GetTable<TodoModel>() .GetTable<TodoModel>()
.Where(x => x.UserId == userId && x.ArchiveId == null) .Where(x => x.UserId == userId && (!onlyDone || x.IsDone) && x.ArchiveId == null)
.Set(x => x.ArchiveId, inserted.Id) .Set(x => x.ArchiveId, inserted.Id)
.UpdateAsync(); .UpdateAsync();

View file

@ -1441,6 +1441,11 @@ todoarchivedelete:
- del - del
- remove - remove
- rm - rm
todoarchivedone:
- done
- compelete
- finish
- completed
todoedit: todoedit:
- edit - edit
- change - change

View file

@ -4524,6 +4524,13 @@ todoarchiveadd:
params: params:
- name: - name:
desc: "The name of the archive to be created." desc: "The name of the archive to be created."
todoarchivedone:
desc: Creates a new archive with the specified name using only completed current todos.
ex:
- Success!
params:
- name:
desc: "The name of the archive to be created."
todoarchivelist: todoarchivelist:
desc: Lists all archived todo lists. desc: Lists all archived todo lists.
ex: ex: