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.