Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions FableBot/FableBot.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>13.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>FableBot</RootNamespace>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\ImageGenerationClasses\ImageGenerationClasses.csproj" />
</ItemGroup>

</Project>
302 changes: 302 additions & 0 deletions FableBot/FableBotDiscordClient.cs
Original file line number Diff line number Diff line change
@@ -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<FableBotAttachment> 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<FableBotIdentity> 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<FableBotChannelInfo> 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<FableBotPostedMessage> PostMessageAsync(
ulong channelId,
string content,
IReadOnlyList<FableBotAttachment> 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<string>() },
};
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<JsonDocument> 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] + "…";
}
}
}
Loading