From fe72200327e197f3198c3bef8ed4fb32843a0ce8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 22:47:38 +0000 Subject: [PATCH] add FableBot: standalone Discord bot-account poster project New FableBot/ console project posts text + file attachments into one Discord channel via REST v10 with a bot token. Config through the standard settings.json (FableBotDiscordBotToken + FableBotDiscordChannelId, both-or-neither, fail-closed validation in Settings.Validate). --check verifies token + channel without posting. Works with a new bot application or the existing SocialAI bot token. PRD in docs/fablebot-discord-prd.md; tests in FableBotTests. Co-authored-by: Ernest French --- AGENTS.md | 3 +- FableBot/FableBot.csproj | 15 + FableBot/FableBotDiscordClient.cs | 302 ++++++++++++++++++ FableBot/Program.cs | 192 +++++++++++ ImageGenerationClasses/Settings.cs | 38 +++ MultiImageClient.Tests/FableBotTests.cs | 151 +++++++++ .../MultiImageClient.Tests.csproj | 1 + MultiImageClient.sln | 18 ++ ...settings - Fill this in and rename it.json | 2 + docs/fablebot-discord-prd.md | 140 ++++++++ 10 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 FableBot/FableBot.csproj create mode 100644 FableBot/FableBotDiscordClient.cs create mode 100644 FableBot/Program.cs create mode 100644 MultiImageClient.Tests/FableBotTests.cs create mode 100644 docs/fablebot-discord-prd.md diff --git a/AGENTS.md b/AGENTS.md index 333acc1..199ca71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ C# desktop app that chains together image generation steps across multiple APIs - [docs/ui-composer-layout.md](docs/ui-composer-layout.md) — **settled 2026-09-03:** composer prompt grows to fill the image/prompt row; paste-zone actions share one row. - [docs/ui-viewer-composer-activation.md](docs/ui-viewer-composer-activation.md) — **settled 2026-09-03:** viewer copy-prompt control and composer activation buttons sit at the top of the status column. - [docs/describe-endpoints.md](docs/describe-endpoints.md) — UI describe + layout-map model IDs (reviewed 2026-09-03): gpt-5.6-sol, claude-sonnet-5, gemini-3.5-flash, grok-4.6, Ideogram `/describe` V_3 +- [docs/fablebot-discord-prd.md](docs/fablebot-discord-prd.md) — **implemented 2026-09-04: FableBot** (`FableBot/` project): standalone console poster that sends text + file attachments into one Discord channel as a bot account (REST v10, `Authorization: Bot`, no gateway in v1); target is a private channel on the owner's server. Config = `FableBotDiscordBotToken` + `FableBotDiscordChannelId` in settings.json (both-or-neither, validated fail-closed in `Settings.Validate()`); works with a new bot application or the existing SocialAI bot token. `--check` verifies token + channel without posting; limits (2000 chars, 10 files, 10 MiB/file) enforced locally fail-closed; mentions always disabled; no 429 retry in v1. Owner setup checklist, decisions, and CLI usage in the doc. Distinct from the vibecoders webhook sender, which is unchanged. ## Record Requirements and Decisions in the Same Change @@ -85,7 +86,7 @@ supersede older production figures later in this file. # Repository Guidelines ## Project Structure & Module Organization -`MultiImageClient/` hosts the C# console orchestrator; `Program.cs` wires runs. `Workflows/` handles execution pipelines (`BatchWorkflow`, `RoundTripWorkflow`, `GeneratorGroups`); `ImageGenerators/` holds one adapter per provider — BFL, Ideogram v2 + v3, GPT-Image-1, GPT-Image-2, Recraft, Google Gemini image, Google Imagen 4 (DALL·E 3 removed 2026-07 after the 2026-05-12 API shutdown); `Describers/` implements image→text (Claude, OpenAI, Gemini, local InternVL, local Qwen); `promptGenerators/` produces prompt sources; `promptTransformation/` rewrites text (Claude rewrite, randomizer, stylizer); `Utils/` supplies helpers. Shared contracts and `Settings.cs` live in `ImageGenerationClasses/`. Provider-specific low-level clients sit in `BFLApi/`, `IdeogramAPI/`, and `RecraftAPI/`. `djangoManager/` contains the experimental Django gallery; it hasn't been touched in a year and is not actively developed. `tools/` holds standalone Python utilities, notably `tools/vid2img/` — the video→context→gpt-image-2 workflow (yt-dlp download, ffmpeg frames + scene cuts, timestamped contact sheets, faster-whisper transcript, iterative `gen` against `/v1/images/edits`; one module per pipeline step, see its README). `do_flask_intern.py` is an optional local InternVL3 Flask server; `save_b64.py` decodes base64 responses. Generated artifacts collect in `saves/` and `output*.png` — ignore them in commits. +`MultiImageClient/` hosts the C# console orchestrator; `Program.cs` wires runs. `Workflows/` handles execution pipelines (`BatchWorkflow`, `RoundTripWorkflow`, `GeneratorGroups`); `ImageGenerators/` holds one adapter per provider — BFL, Ideogram v2 + v3, GPT-Image-1, GPT-Image-2, Recraft, Google Gemini image, Google Imagen 4 (DALL·E 3 removed 2026-07 after the 2026-05-12 API shutdown); `Describers/` implements image→text (Claude, OpenAI, Gemini, local InternVL, local Qwen); `promptGenerators/` produces prompt sources; `promptTransformation/` rewrites text (Claude rewrite, randomizer, stylizer); `Utils/` supplies helpers. Shared contracts and `Settings.cs` live in `ImageGenerationClasses/`. Provider-specific low-level clients sit in `BFLApi/`, `IdeogramAPI/`, and `RecraftAPI/`. `FableBot/` is the standalone Discord bot-account poster (see [docs/fablebot-discord-prd.md](docs/fablebot-discord-prd.md)). `djangoManager/` contains the experimental Django gallery; it hasn't been touched in a year and is not actively developed. `tools/` holds standalone Python utilities, notably `tools/vid2img/` — the video→context→gpt-image-2 workflow (yt-dlp download, ffmpeg frames + scene cuts, timestamped contact sheets, faster-whisper transcript, iterative `gen` against `/v1/images/edits`; one module per pipeline step, see its README). `do_flask_intern.py` is an optional local InternVL3 Flask server; `save_b64.py` decodes base64 responses. Generated artifacts collect in `saves/` and `output*.png` — ignore them in commits. ## Build, Test, and Development Commands All projects target plain `net10.0` (retargeted from `net9.0` on 2026-08-05; `MultiImageClient.Web` is `net10.0-windows`) and are cross-platform — the app runs fine on Linux (compositing uses ImageSharp/Magick.NET, not WinForms; verified in production use on a Linux box, 2026-08-03). Verify the SDK with `dotnet --list-sdks`; if 10.x is missing, `winget install Microsoft.DotNet.SDK.10` on Windows or `dotnet-install.sh --channel 10.0` on Linux (fuseki runs it from `tparkour`'s `~/.dotnet`). Restore with `dotnet restore MultiImageClient.sln`. Compile with `dotnet build MultiImageClient.sln`. Execute runs with `dotnet run --project MultiImageClient/MultiImageClient.csproj`; prompts come from `prompts.txt` and `settings.json` (the latter must be created by copying the template `settings - Fill this in and rename it.json`). On current `master` the build is clean (0 errors); ~90 warnings are all `NU190x` advisories for `Magick.NET-Q16-AnyCPU 14.8.2` — safe to bump to `14.12.0` when convenient. For the Django tooling, create a venv in `djangoManager/`, install `requirements.txt`, and launch `python djangoManager/imageMaker/manage.py runserver`. Run `dotnet format MultiImageClient.sln` before opening a PR. diff --git a/FableBot/FableBot.csproj b/FableBot/FableBot.csproj new file mode 100644 index 0000000..9a9f1f9 --- /dev/null +++ b/FableBot/FableBot.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + 13.0 + enable + FableBot + + + + + + + diff --git a/FableBot/FableBotDiscordClient.cs b/FableBot/FableBotDiscordClient.cs new file mode 100644 index 0000000..cda89a8 --- /dev/null +++ b/FableBot/FableBotDiscordClient.cs @@ -0,0 +1,302 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace FableBot +{ + /// Pure validation and message-shape rules for FableBot's Discord + /// posting, kept static and network-free so tests cover them directly. + public static class FableBotDiscord + { + // Discord hard limit for message content. + public const int MaxMessageChars = 2000; + + // Discord hard limit for attachments on one message. + public const int MaxAttachmentsPerMessage = 10; + + // Upload cap for servers without a boost tier. + public const long MaxAttachmentBytes = 10L * 1024 * 1024; + + public static bool TryNormalizeChannelId(string? raw, out ulong channelId) + { + channelId = 0; + var s = raw?.Trim() ?? ""; + // Snowflakes are 17-20 digits today; allow small margins. + if (s.Length < 15 || s.Length > 21) + { + return false; + } + foreach (var c in s) + { + if (c < '0' || c > '9') + { + return false; + } + } + return ulong.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out channelId) + && channelId != 0; + } + + public static bool TryNormalizeBotToken(string? raw, out string token) + { + token = ""; + var s = raw?.Trim() ?? ""; + if (s.Length < 50) + { + return false; + } + foreach (var c in s) + { + if (char.IsWhiteSpace(c)) + { + return false; + } + } + token = s; + return true; + } + + /// Throws with the exact rule violated; returns silently when the + /// message is postable. Content and attachments are both optional, + /// but at least one must be present. + public static void ValidateOutgoingMessage( + string content, + IReadOnlyList attachments) + { + if (content.Length == 0 && attachments.Count == 0) + { + throw new InvalidOperationException( + "A Discord message needs text content, at least one file, or both."); + } + if (content.Length > MaxMessageChars) + { + throw new InvalidOperationException( + $"Message content is {content.Length} characters; Discord's limit is {MaxMessageChars}. Shorten the message."); + } + if (attachments.Count > MaxAttachmentsPerMessage) + { + throw new InvalidOperationException( + $"{attachments.Count} files were given; Discord allows at most {MaxAttachmentsPerMessage} per message."); + } + foreach (var attachment in attachments) + { + if (attachment.Bytes.Length == 0) + { + throw new InvalidOperationException( + $"Attachment '{attachment.FileName}' is empty."); + } + if (attachment.Bytes.Length > MaxAttachmentBytes) + { + throw new InvalidOperationException( + $"Attachment '{attachment.FileName}' is {attachment.Bytes.Length} bytes; the upload cap is {MaxAttachmentBytes} bytes (10 MiB)."); + } + } + } + + /// Magic-byte sniffing for the media types FableBot posts. Unknown + /// bytes are sent as application/octet-stream, which Discord accepts + /// for any attachment; the true bytes travel verbatim either way. + public static string DetectContentType(byte[] bytes) + { + if (bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + { + return "image/png"; + } + if (bytes.Length >= 3 + && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + { + return "image/jpeg"; + } + if (bytes.Length >= 12 + && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 + && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) + { + return "image/webp"; + } + if (bytes.Length >= 6 + && bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38) + { + return "image/gif"; + } + if (bytes.Length >= 12 + && bytes[4] == 0x66 && bytes[5] == 0x74 && bytes[6] == 0x79 && bytes[7] == 0x70) + { + return "video/mp4"; + } + return "application/octet-stream"; + } + } + + public sealed record FableBotAttachment(string FileName, byte[] Bytes); + + public sealed record FableBotIdentity(string Id, string Username); + + public sealed record FableBotChannelInfo(string Id, int Type, string Name, string? GuildId); + + public sealed record FableBotPostedMessage(string MessageId, string ChannelId, string? GuildId) + { + // guild_id is absent from REST message responses; the caller supplies + // it from the channel lookup so a jump link can be printed. + public string JumpUrl => + $"https://discord.com/channels/{GuildId ?? "@me"}/{ChannelId}/{MessageId}"; + } + + /// Minimal Discord bot REST client (API v10). Post-only in v1: identity + /// check, channel lookup, and message create. No gateway connection. + public sealed class FableBotDiscordClient + { + private const string ApiBase = "https://discord.com/api/v10"; + + private static readonly HttpClient Http = new() + { + Timeout = TimeSpan.FromSeconds(60), + }; + + private readonly string _token; + + public FableBotDiscordClient(string botToken) + { + if (!FableBotDiscord.TryNormalizeBotToken(botToken, out var token)) + { + throw new InvalidOperationException( + "FableBotDiscordBotToken is not a usable bot token (blank, too short, or contains whitespace)."); + } + _token = token; + } + + public async Task GetBotIdentityAsync(CancellationToken cancellationToken) + { + using var document = await GetJsonAsync("/users/@me", cancellationToken); + var root = document.RootElement; + var id = RequiredString(root, "id", "GET /users/@me"); + var username = RequiredString(root, "username", "GET /users/@me"); + return new FableBotIdentity(id, username); + } + + public async Task GetChannelAsync( + ulong channelId, CancellationToken cancellationToken) + { + using var document = await GetJsonAsync($"/channels/{channelId}", cancellationToken); + var root = document.RootElement; + var id = RequiredString(root, "id", $"GET /channels/{channelId}"); + if (!root.TryGetProperty("type", out var typeElement) + || typeElement.ValueKind != JsonValueKind.Number) + { + throw new InvalidOperationException( + $"Discord's channel response for {channelId} is missing the numeric 'type' field."); + } + var name = root.TryGetProperty("name", out var nameElement) + && nameElement.ValueKind == JsonValueKind.String + ? nameElement.GetString() ?? "" + : ""; + string? guildId = root.TryGetProperty("guild_id", out var guildElement) + && guildElement.ValueKind == JsonValueKind.String + ? guildElement.GetString() + : null; + return new FableBotChannelInfo(id, typeElement.GetInt32(), name, guildId); + } + + public async Task PostMessageAsync( + ulong channelId, + string content, + IReadOnlyList attachments, + string? guildIdForLink, + CancellationToken cancellationToken) + { + FableBotDiscord.ValidateOutgoingMessage(content, attachments); + + using var form = new MultipartFormDataContent(); + var payload = new + { + content = content.Length == 0 ? null : content, + allowed_mentions = new { parse = Array.Empty() }, + }; + form.Add( + new StringContent( + JsonSerializer.Serialize( + payload, + new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }), + Encoding.UTF8), + "payload_json"); + for (int i = 0; i < attachments.Count; i++) + { + var file = new ByteArrayContent(attachments[i].Bytes); + file.Headers.ContentType = new MediaTypeHeaderValue( + FableBotDiscord.DetectContentType(attachments[i].Bytes)); + form.Add(file, $"files[{i}]", attachments[i].FileName); + } + + using var request = BuildRequest(HttpMethod.Post, $"/channels/{channelId}/messages"); + request.Content = form; + using var response = await Http.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Discord rejected the message ({(int)response.StatusCode}): {Truncate(body)}"); + } + + using var document = JsonDocument.Parse(body); + var messageId = RequiredString( + document.RootElement, "id", $"POST /channels/{channelId}/messages"); + var returnedChannelId = RequiredString( + document.RootElement, "channel_id", $"POST /channels/{channelId}/messages"); + return new FableBotPostedMessage(messageId, returnedChannelId, guildIdForLink); + } + + private async Task GetJsonAsync( + string path, CancellationToken cancellationToken) + { + using var request = BuildRequest(HttpMethod.Get, path); + using var response = await Http.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Discord rejected {path} ({(int)response.StatusCode}): {Truncate(body)}"); + } + return JsonDocument.Parse(body); + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string path) + { + var request = new HttpRequestMessage(method, ApiBase + path); + request.Headers.TryAddWithoutValidation("Authorization", "Bot " + _token); + // Discord requires bots to send a DiscordBot user agent. + request.Headers.TryAddWithoutValidation( + "User-Agent", + "DiscordBot (https://github.com/ernop/multiImageClient, 1.0)"); + return request; + } + + private static string RequiredString(JsonElement root, string property, string operation) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty(property, out var element) + || element.ValueKind != JsonValueKind.String + || string.IsNullOrEmpty(element.GetString())) + { + throw new InvalidOperationException( + $"Discord's response to {operation} is missing the '{property}' field; refusing to continue with a partial response."); + } + return element.GetString()!; + } + + private static string Truncate(string body) + { + var trimmed = body.Trim(); + return trimmed.Length <= 600 ? trimmed : trimmed[..600] + "…"; + } + } +} diff --git a/FableBot/Program.cs b/FableBot/Program.cs new file mode 100644 index 0000000..c1b8216 --- /dev/null +++ b/FableBot/Program.cs @@ -0,0 +1,192 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using MultiImageClient; + +namespace FableBot +{ + /// FableBot: post a message (text and/or files) into one Discord channel + /// as a bot account. Configuration comes from the standard + /// MultiImageClient settings.json (FableBotDiscordBotToken + + /// FableBotDiscordChannelId). See docs/fablebot-discord-prd.md. + public static class Program + { + private const string Usage = """ + FableBot — post to Discord as a bot account. + + usage: + dotnet run --project FableBot -- --check + dotnet run --project FableBot -- --message "text" [--file path]... [--channel id] + + options: + --check Verify the token and channel without posting: + prints the bot's username and the channel name. + --message TEXT Message text (max 2000 characters). + --file PATH Attach a file (repeatable, max 10, each <= 10 MiB). + --channel ID Post to this channel id instead of the settings + default FableBotDiscordChannelId. + --help Show this text. + + configuration (settings.json, or MULTIIMAGECLIENT_SETTINGS): + FableBotDiscordBotToken raw bot token, no "Bot " prefix + FableBotDiscordChannelId default target channel id (snowflake) + + exit codes: 0 posted/verified, 1 usage or configuration error, + 2 Discord rejected the request. + """; + + public static async Task Main(string[] args) + { + bool check = false; + string message = ""; + string channelOverride = ""; + var filePaths = new List(); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--help" or "-h" or "/?": + Console.WriteLine(Usage); + return 0; + case "--check": + check = true; + break; + case "--message" when i + 1 < args.Length: + message = args[++i]; + break; + case "--file" when i + 1 < args.Length: + filePaths.Add(args[++i]); + break; + case "--channel" when i + 1 < args.Length: + channelOverride = args[++i]; + break; + default: + Console.Error.WriteLine($"Unknown or incomplete argument: {args[i]}"); + Console.Error.WriteLine(Usage); + return 1; + } + } + + if (!check && message.Length == 0 && filePaths.Count == 0) + { + Console.Error.WriteLine(Usage); + return 1; + } + + Settings settings; + try + { + settings = Settings.LoadFromFile(ResolveSettingsPath()); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Settings error: {ex.Message}"); + return 1; + } + + if (!FableBotDiscord.TryNormalizeBotToken( + settings.FableBotDiscordBotToken, out var token)) + { + Console.Error.WriteLine( + "FableBotDiscordBotToken is not set in settings.json. Fill in FableBotDiscordBotToken and FableBotDiscordChannelId; see docs/fablebot-discord-prd.md."); + return 1; + } + var channelRaw = channelOverride.Length > 0 + ? channelOverride + : settings.FableBotDiscordChannelId; + if (!FableBotDiscord.TryNormalizeChannelId(channelRaw, out var channelId)) + { + Console.Error.WriteLine( + channelOverride.Length > 0 + ? $"--channel '{channelOverride}' is not a numeric Discord channel id." + : "FableBotDiscordChannelId is not set in settings.json (or is not a numeric channel id). See docs/fablebot-discord-prd.md."); + return 1; + } + + var attachments = new List(); + foreach (var path in filePaths) + { + if (!File.Exists(path)) + { + Console.Error.WriteLine($"--file '{path}' does not exist."); + return 1; + } + attachments.Add(new FableBotAttachment( + Path.GetFileName(path), + await File.ReadAllBytesAsync(path))); + } + + var client = new FableBotDiscordClient(token); + try + { + var identity = await client.GetBotIdentityAsync(CancellationToken.None); + var channel = await client.GetChannelAsync(channelId, CancellationToken.None); + Console.WriteLine( + $"bot: {identity.Username} (id {identity.Id}) channel: #{channel.Name} (id {channel.Id}, type {DescribeChannelType(channel.Type)})"); + + if (check) + { + Console.WriteLine("check: PASS — the token works and the bot can see the channel."); + return 0; + } + + var posted = await client.PostMessageAsync( + channelId, message, attachments, channel.GuildId, CancellationToken.None); + Console.WriteLine($"posted message {posted.MessageId}: {posted.JumpUrl}"); + return 0; + } + catch (InvalidOperationException ex) + { + Console.Error.WriteLine(ex.Message); + return 2; + } + } + + private static string DescribeChannelType(int type) + { + return type switch + { + 0 => "guild text", + 1 => "DM", + 2 => "guild voice", + 5 => "announcement", + 10 or 11 or 12 => "thread", + 15 => "forum", + _ => $"unknown ({type})", + }; + } + + // Mirrors MultiImageClient/Program.ResolveSettingsPath: an explicit + // MULTIIMAGECLIENT_SETTINGS path wins and stays fail-closed; otherwise + // search the obvious repo locations for settings.json. + private static string ResolveSettingsPath() + { + var configuredPath = Environment.GetEnvironmentVariable("MULTIIMAGECLIENT_SETTINGS"); + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return Path.GetFullPath(Settings.ExpandPath(configuredPath.Trim())); + } + + var candidates = new[] + { + "settings.json", + Path.Combine("MultiImageClient", "settings.json"), + Path.Combine("..", "MultiImageClient", "settings.json"), + Path.Combine(AppContext.BaseDirectory, "settings.json"), + }; + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + return "settings.json"; + } + } +} diff --git a/ImageGenerationClasses/Settings.cs b/ImageGenerationClasses/Settings.cs index b3fa30c..b7c39dc 100644 --- a/ImageGenerationClasses/Settings.cs +++ b/ImageGenerationClasses/Settings.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -142,6 +143,18 @@ public class Settings // Example: https://host.example/instance-path (no trailing slash). public string UiPublicBaseUrl { get; set; } = ""; + // FableBot (FableBot/ project): Discord bot-account token used to + // post into channels the bot account is a member of. Both FableBot + // settings are blank by default, which disables FableBot. Reuse the + // SocialAI bot's token or create a new bot application; see + // docs/fablebot-discord-prd.md for the setup checklist. + public string FableBotDiscordBotToken { get; set; } = ""; + + // Default Discord channel id (numeric snowflake) FableBot posts to. + // Discord Desktop: Settings > Advanced > Developer Mode, then + // right-click the channel > Copy Channel ID. + public string FableBotDiscordChannelId { get; set; } = ""; + /// Maximum number of memory-heavy UI job finalizations (contact-sheet /// rendering and cleanup) allowed at once. Endpoint requests from /// different jobs are scheduled independently by target and do not @@ -428,6 +441,31 @@ public void Validate() "settings.json: B2KeepLocalRawImages=false requires EnableB2ImageHosting=true — evicting local raw images without an upload destination would discard data."); } + var fableBotToken = FableBotDiscordBotToken?.Trim() ?? ""; + var fableBotChannel = FableBotDiscordChannelId?.Trim() ?? ""; + if (fableBotToken.Length != 0 || fableBotChannel.Length != 0) + { + if (fableBotToken.Length == 0 || fableBotChannel.Length == 0) + { + throw new InvalidOperationException( + "settings.json: FableBotDiscordBotToken and FableBotDiscordChannelId must be set together. Leave both blank to disable FableBot."); + } + if (fableBotToken.Length < 50 + || fableBotToken.Any(char.IsWhiteSpace) + || fableBotToken.StartsWith("Bot ", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "settings.json: FableBotDiscordBotToken must be the raw bot token from the Discord Developer Portal — no whitespace and no 'Bot ' prefix."); + } + if (fableBotChannel.Length < 15 + || fableBotChannel.Length > 21 + || !fableBotChannel.All(char.IsAsciiDigit)) + { + throw new InvalidOperationException( + "settings.json: FableBotDiscordChannelId must be the numeric channel id (snowflake) copied from Discord's Copy Channel ID."); + } + } + var webhook = DiscordVibecodersWebhookUrl?.Trim() ?? ""; var publicBase = UiPublicBaseUrl?.Trim() ?? ""; if (webhook.Length == 0 && publicBase.Length == 0) diff --git a/MultiImageClient.Tests/FableBotTests.cs b/MultiImageClient.Tests/FableBotTests.cs new file mode 100644 index 0000000..ead56b3 --- /dev/null +++ b/MultiImageClient.Tests/FableBotTests.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using System.Text; + +using FableBot; + +using MultiImageClient; + +using Xunit; + +namespace MultiImageClient.Tests +{ + public sealed class FableBotTests + { + private const string PlausibleToken = + "MTAwMDAwMDAwMDAwMDAwMDAw.GaBcDe.fghijklmnopqrstuvwxyz0123456789ABCDEF"; + + [Fact] + public void ChannelIdAcceptsOnlyNumericSnowflakes() + { + Assert.True(FableBotDiscord.TryNormalizeChannelId( + "123456789012345678", out var id)); + Assert.Equal(123456789012345678UL, id); + Assert.True(FableBotDiscord.TryNormalizeChannelId( + " 123456789012345678 ", out _)); + Assert.False(FableBotDiscord.TryNormalizeChannelId("", out _)); + Assert.False(FableBotDiscord.TryNormalizeChannelId("general", out _)); + Assert.False(FableBotDiscord.TryNormalizeChannelId("12345", out _)); + Assert.False(FableBotDiscord.TryNormalizeChannelId( + "12345678901234567x", out _)); + Assert.False(FableBotDiscord.TryNormalizeChannelId( + "<#123456789012345678>", out _)); + } + + [Fact] + public void BotTokenRejectsBlankShortAndWhitespace() + { + Assert.True(FableBotDiscord.TryNormalizeBotToken(PlausibleToken, out var token)); + Assert.Equal(PlausibleToken, token); + Assert.True(FableBotDiscord.TryNormalizeBotToken( + " " + PlausibleToken + " ", out _)); + Assert.False(FableBotDiscord.TryNormalizeBotToken("", out _)); + Assert.False(FableBotDiscord.TryNormalizeBotToken("shorttoken", out _)); + Assert.False(FableBotDiscord.TryNormalizeBotToken( + "Bot " + PlausibleToken, out _)); + } + + [Fact] + public void OutgoingMessageEnforcesDiscordLimits() + { + var oneFile = new[] { new FableBotAttachment("a.png", new byte[] { 1 }) }; + + // Empty everything is rejected. + Assert.Throws(() => + FableBotDiscord.ValidateOutgoingMessage("", Array.Empty())); + + // Content-only, file-only, and both are all valid. + FableBotDiscord.ValidateOutgoingMessage("hi", Array.Empty()); + FableBotDiscord.ValidateOutgoingMessage("", oneFile); + FableBotDiscord.ValidateOutgoingMessage("hi", oneFile); + FableBotDiscord.ValidateOutgoingMessage( + new string('x', FableBotDiscord.MaxMessageChars), + Array.Empty()); + + Assert.Throws(() => + FableBotDiscord.ValidateOutgoingMessage( + new string('x', FableBotDiscord.MaxMessageChars + 1), + Array.Empty())); + + var elevenFiles = new FableBotAttachment[11]; + for (int i = 0; i < elevenFiles.Length; i++) + { + elevenFiles[i] = new FableBotAttachment($"f{i}.png", new byte[] { 1 }); + } + Assert.Throws(() => + FableBotDiscord.ValidateOutgoingMessage("", elevenFiles)); + + Assert.Throws(() => + FableBotDiscord.ValidateOutgoingMessage( + "", new[] { new FableBotAttachment("empty.png", Array.Empty()) })); + } + + [Fact] + public void ContentTypeComesFromMagicBytes() + { + Assert.Equal("image/png", FableBotDiscord.DetectContentType( + new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A })); + Assert.Equal("image/jpeg", FableBotDiscord.DetectContentType( + new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })); + Assert.Equal("image/webp", FableBotDiscord.DetectContentType( + Encoding.ASCII.GetBytes("RIFF....WEBP"))); + Assert.Equal("image/gif", FableBotDiscord.DetectContentType( + Encoding.ASCII.GetBytes("GIF89a"))); + Assert.Equal("application/octet-stream", FableBotDiscord.DetectContentType( + Encoding.ASCII.GetBytes("plain text"))); + } + + [Fact] + public void PostedMessageBuildsJumpUrlFromExactIdentity() + { + var posted = new FableBotPostedMessage("333", "222", "111"); + Assert.Equal("https://discord.com/channels/111/222/333", posted.JumpUrl); + var noGuild = new FableBotPostedMessage("333", "222", null); + Assert.Equal("https://discord.com/channels/@me/222/333", noGuild.JumpUrl); + } + + [Fact] + public void SettingsRequireTokenAndChannelTogether() + { + var folder = Directory.CreateTempSubdirectory("mic-fablebot-").FullName; + try + { + Settings MakeSettings() => new Settings + { + LogFilePath = Path.Combine(folder, "test.log"), + ImageDownloadBaseFolder = folder, + }; + + // Both blank: FableBot disabled, settings valid. + MakeSettings().Validate(); + + var tokenOnly = MakeSettings(); + tokenOnly.FableBotDiscordBotToken = PlausibleToken; + Assert.Throws(() => tokenOnly.Validate()); + + var channelOnly = MakeSettings(); + channelOnly.FableBotDiscordChannelId = "123456789012345678"; + Assert.Throws(() => channelOnly.Validate()); + + var badChannel = MakeSettings(); + badChannel.FableBotDiscordBotToken = PlausibleToken; + badChannel.FableBotDiscordChannelId = "not-a-snowflake"; + Assert.Throws(() => badChannel.Validate()); + + var badToken = MakeSettings(); + badToken.FableBotDiscordBotToken = "Bot " + PlausibleToken; + badToken.FableBotDiscordChannelId = "123456789012345678"; + Assert.Throws(() => badToken.Validate()); + + var good = MakeSettings(); + good.FableBotDiscordBotToken = PlausibleToken; + good.FableBotDiscordChannelId = "123456789012345678"; + good.Validate(); + } + finally + { + Directory.Delete(folder, true); + } + } + } +} diff --git a/MultiImageClient.Tests/MultiImageClient.Tests.csproj b/MultiImageClient.Tests/MultiImageClient.Tests.csproj index ac22a52..85b14f8 100644 --- a/MultiImageClient.Tests/MultiImageClient.Tests.csproj +++ b/MultiImageClient.Tests/MultiImageClient.Tests.csproj @@ -21,6 +21,7 @@ + \ No newline at end of file diff --git a/MultiImageClient.sln b/MultiImageClient.sln index ebd6f19..226299f 100644 --- a/MultiImageClient.sln +++ b/MultiImageClient.sln @@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiImageClient.Web", "Mul EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiImageClient.Tests", "MultiImageClient.Tests\MultiImageClient.Tests.csproj", "{75493642-BC17-4569-888E-400E94A12BE4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FableBot", "FableBot\FableBot.csproj", "{B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -164,6 +166,22 @@ Global {75493642-BC17-4569-888E-400E94A12BE4}.Release|x64.Build.0 = Release|Any CPU {75493642-BC17-4569-888E-400E94A12BE4}.Release|x86.ActiveCfg = Release|Any CPU {75493642-BC17-4569-888E-400E94A12BE4}.Release|x86.Build.0 = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|ARM64.Build.0 = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|x64.Build.0 = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Debug|x86.Build.0 = Debug|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|Any CPU.Build.0 = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|ARM64.ActiveCfg = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|ARM64.Build.0 = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|x64.ActiveCfg = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|x64.Build.0 = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|x86.ActiveCfg = Release|Any CPU + {B7F0E2C9-4A31-4E64-9D18-2FC5D9A7E301}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MultiImageClient/settings - Fill this in and rename it.json b/MultiImageClient/settings - Fill this in and rename it.json index 7a66962..0c7c739 100644 --- a/MultiImageClient/settings - Fill this in and rename it.json +++ b/MultiImageClient/settings - Fill this in and rename it.json @@ -38,6 +38,8 @@ "UiCommunityDbPath": "", "DiscordVibecodersWebhookUrl": "", "UiPublicBaseUrl": "", + "FableBotDiscordBotToken": "", + "FableBotDiscordChannelId": "", "EnableLogging": true, "AnnotationSide": "right", "FlatImageMirrorPath": "", diff --git a/docs/fablebot-discord-prd.md b/docs/fablebot-discord-prd.md new file mode 100644 index 0000000..0880917 --- /dev/null +++ b/docs/fablebot-discord-prd.md @@ -0,0 +1,140 @@ +# FableBot — Discord bot posting interface + +Status: v1 implemented 2026-09-04 (post-only). Owner setup pending: pick a +bot token and channel id, fill in settings, run `--check`, then a live post. + +## What it is + +`FableBot/` is a standalone console project in `MultiImageClient.sln` that +posts messages — text and/or file attachments — into one Discord channel as a +**bot account**, over the Discord REST API v10 with an +`Authorization: Bot ` header. The v1 target is a private channel on +the owner's server ("Ernie server"). No gateway/WebSocket connection exists +in v1; the bot only posts, checks its identity, and looks up the channel. + +A bot account was chosen over a second incoming webhook (the existing +`DiscordVibecodersWebhookUrl` path) because a bot account: + +- can post into any private channel it is added to, without a per-channel + webhook, +- has a real member identity (name, avatar, presence) on the server, +- can later be extended to read, reply, and react (gateway or slash + commands), which a webhook can never do. + +The existing vibecoders webhook sender (`DiscordVibecoders.cs`) is unchanged +and remains the `--ui` share button's transport. + +## Requirements + +| # | Requirement | Status | +|---|---|---| +| R1 | Post a text message to one configured Discord channel as a bot account. | Implemented | +| R2 | Attach files (images, video, arbitrary bytes) to a post; bytes travel verbatim. | Implemented | +| R3 | Live in the MultiImageClient solution as its own project (`FableBot/`). | Implemented | +| R4 | Work with either a newly created bot application or an existing one (for example the SocialAI bot); the interface takes whatever token is configured. | Implemented | +| R5 | Configuration through the standard `settings.json` (`FableBotDiscordBotToken`, `FableBotDiscordChannelId`), never committed. | Implemented | +| R6 | Fail closed: missing/partial configuration, non-numeric channel ids, oversized or empty messages, and every Discord rejection are hard errors. No retries, no substitution. | Implemented | +| R7 | `--check` verifies the token and channel visibility without posting. | Implemented | + +## Settled decisions (2026-09-04) + +- **Transport:** raw HTTPS against `https://discord.com/api/v10` with + `System.Net.Http` + `System.Text.Json`. No Discord NuGet library — v1 needs + three endpoints (`GET /users/@me`, `GET /channels/{id}`, + `POST /channels/{id}/messages`), and the repo's other providers are also + hand-rolled HTTP clients. Revisit only if a gateway listener is added. +- **Identity checks before posting:** every run resolves the bot identity and + the channel first. This turns a bad token or a channel the bot cannot see + into a specific error before any message is created, and supplies the + `guild_id` for the printed jump link (REST message responses omit it). +- **Message limits enforced locally, fail-closed:** content over 2000 + characters, more than 10 files, an empty file, or a file over 10 MiB is + rejected before the request is sent — never truncated or split. +- **Mentions disabled:** every post sends `allowed_mentions: {"parse": []}`, + so pasted `@everyone`/user mentions never ping. +- **Content types from magic bytes:** PNG/JPEG/WEBP/GIF/MP4 are detected from + the file's leading bytes; anything else is sent as + `application/octet-stream`. Discord accepts any attachment type; the bytes + are never re-encoded. +- **No retry on 429:** a rate-limit response is a visible failure carrying + Discord's body (which includes `retry_after`). v1 is a manual, low-volume + poster; automated retry policy is deferred until an automated caller + exists. +- **Settings validation is in `Settings.Validate()`:** both keys blank + disables FableBot; one set without the other, a whitespace/short token, a + `Bot `-prefixed token, or a non-numeric channel id fails settings load for + every run mode, matching the vibecoders pair rule. +- **Namespace `FableBot`, single level,** per the repository namespace rule. +- **Not wired into `--ui` in v1.** The first consumer is the CLI. Posting + generated results from the web UI is future work and will reuse + `FableBotDiscordClient`. + +## Configuration + +In `settings.json` (or the file named by `MULTIIMAGECLIENT_SETTINGS`): + +```json +"FableBotDiscordBotToken": "", +"FableBotDiscordChannelId": "" +``` + +Both blank (the default) disables FableBot. The token is a secret; keep it +out of git (settings.json is already gitignored). + +## Owner setup checklist + +Option A — new bot (recommended so FableBot has its own name/avatar): + +1. Open https://discord.com/developers/applications and create an + application named `FableBot`. +2. On the Bot page: copy the token (Reset Token if needed). Privileged + intents are NOT needed for posting. +3. Turn off "Public Bot" so only you can install it. +4. Invite it to the server: OAuth2 URL Generator, scope `bot`, permissions + View Channel + Send Messages + Attach Files (permissions integer + `35840`), open the generated URL, pick the Ernie server. +5. Because the target channel is private, also grant the bot access there: + channel settings > Permissions > add the FableBot role or member with + View Channel + Send Messages + Attach Files. +6. Enable Developer Mode (User Settings > Advanced), right-click the + channel, Copy Channel ID. +7. Fill in the two settings keys and run the check below. + +Option B — reuse the SocialAI bot: take its existing token from the SocialAI +project (`/proj/SocialAI/`), confirm that bot is on the Ernie server, grant +it the private channel per step 5, and use that token. FableBot posts under +that bot's name; the two projects share one identity and one rate-limit +budget. Any later FableBot permission change also affects SocialAI. + +## CLI usage + +```bash +dotnet run --project FableBot -- --check +dotnet run --project FableBot -- --message "hello from FableBot" +dotnet run --project FableBot -- --message "with a picture" --file saves/example.png +dotnet run --project FableBot -- --message "elsewhere" --channel 123456789012345678 +``` + +Exit codes: 0 posted/verified, 1 usage or configuration error, 2 Discord +rejected the request. A successful post prints the message id and the +`https://discord.com/channels/{guild}/{channel}/{message}` jump link. + +## Files + +- `FableBot/FableBot.csproj` — console project, references + `ImageGenerationClasses` for `Settings`. +- `FableBot/FableBotDiscordClient.cs` — `FableBotDiscord` (static limits + + validation + magic-byte content-type detection), `FableBotDiscordClient` + (REST calls), records for identity/channel/posted-message. +- `FableBot/Program.cs` — argument parsing, settings resolution (same + search order as the main app), check/post flows. +- `ImageGenerationClasses/Settings.cs` — the two `FableBot*` keys and their + fail-closed validation. +- `MultiImageClient.Tests/FableBotTests.cs` — snowflake/token/message-limit/ + content-type/jump-link/settings-validation tests (no network). + +## Future work (not in v1) + +- Post finished `--ui` results (reusing the vibecoders share-claim pattern). +- Read/reply: gateway connection or slash-command interactions. +- Automated posting policy, including a researched 429 retry contract.