From 5d420d75deefc09bd07cca623da71e188bde1f65 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Fri, 9 Feb 2024 17:10:35 +0900 Subject: [PATCH] Add apps --- Directory.Packages.props | 1 + apps/bskycli/Program.cs | 416 ++++++++++++++++++++++++++++++++++++ apps/bskycli/bskycli.csproj | 29 +++ apps/bskycli/bskycli.sln | 28 +++ 4 files changed, 474 insertions(+) create mode 100644 apps/bskycli/Program.cs create mode 100644 apps/bskycli/bskycli.csproj create mode 100644 apps/bskycli/bskycli.sln diff --git a/Directory.Packages.props b/Directory.Packages.props index 7eef8be..3d8e9b7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/apps/bskycli/Program.cs b/apps/bskycli/Program.cs new file mode 100644 index 0000000..db88033 --- /dev/null +++ b/apps/bskycli/Program.cs @@ -0,0 +1,416 @@ +// +// Copyright (c) Drastic Actions. All rights reserved. +// + +using System.Net.Http.Headers; +using System.Text; +using System.Text.RegularExpressions; +using DotMake.CommandLine; +using FishyFlip; +using FishyFlip.Models; +using FishyFlip.Tools; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +#if DEBUG +var loggerFactory = LoggerFactory.Create( + builder => builder + .AddConsole() + .AddDebug() + .SetMinimumLevel(LogLevel.Debug)); +#else +var loggerFactory = LoggerFactory.Create( + builder => builder + .AddDebug() + .SetMinimumLevel(LogLevel.Debug)); +#endif + +var logger = loggerFactory.CreateLogger(); + +Cli.Ext.ConfigureServices(service => +{ + service.AddSingleton(loggerFactory); +}); + +try +{ + logger.LogDebug("Console Arguments: bskycli" + string.Join(" ", args)); + await Cli.RunAsync(args); +} +catch (Exception e) +{ + logger.LogError(e, "An error occurred."); +} + +/// +/// Root CLI command. +/// +[CliCommand(Description = "bskycli", ShortFormAutoGenerate = false)] +public class RootCommand +{ + /// + /// Post Command. + /// + [CliCommand(Description = "Post a message with images")] + public class ImagePostCommand(ILoggerFactory loggerFactory) : CredentialCommandBase(loggerFactory) + { + /// + /// Gets or sets the message to post. + /// + [CliOption(Description = "Text to post")] + public string? Text { get; set; } + + [CliOption( + Description = "Images to post. Max of 4.", + AllowMultipleArgumentsPerToken = true, + ValidationRules = CliValidationRules.ExistingFile)] + public required IEnumerable Images { get; set; } + + [CliOption(Description = "Alt Text for images. Max of 4. Text is mapped to images in order of entry.", AllowMultipleArgumentsPerToken = true, Required = false)] + public IEnumerable? AltText { get; set; } + + /// + /// Run the command. + /// + /// A representing the asynchronous operation. + public override async Task RunAsync() + { + var logger = loggerFactory.CreateLogger(); + if (!this.Images.Any()) + { + throw new Exception("Images are required."); + } + + var parsedText = MarkdownLinkParser.ParseMarkdown(this.Text ?? string.Empty); + var facets = MarkdownLinkParser.GenerateFacets(parsedText); + var images = this.Images?.Take(4).ToList() ?? throw new Exception("No images provided."); + var alts = this.AltText?.Take(4).ToList() ?? new List(); + + var imageEmbeds = new List(); + var fileDetector = new FileContentTypeDetector(logger); + + // Verify the images before we upload + var contentTypes = new List(); + foreach (var image in images) + { + await using var fileStream = image.OpenRead(); + var contentType = fileDetector.GetContentType(fileStream); + if (contentType == "unsupported") + { + throw new Exception($"Unsupported file type for {image.FullName}"); + } + + contentTypes.Add(contentType); + } + + if (contentTypes.Any(x => x != "image/jpeg" && x != "image/png" && x != "image/gif")) + { + throw new Exception("Unsupported file type."); + } + + // Log in and upload the images. + await base.RunAsync(); + + for (var i = 0; i < this.Images.Count(); i++) + { + var image = images.ElementAt(i); + var altText = alts.ElementAtOrDefault(i) ?? string.Empty; + var contentType = contentTypes.ElementAt(i); + await using var fileStream = image.OpenRead(); + var content = new StreamContent(fileStream); + content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + var blobResult = (await this.ATProtocol!.Repo.UploadBlobAsync(content)).HandleResult(); + logger.LogDebug($"Uploaded {image.Name} to {blobResult.Blob.Ref}"); + var img = blobResult.Blob.ToImage() ?? throw new Exception($"Failed to convert blob {image.Name} to image."); + var imgEmbed = new ImageEmbed(img, altText); + logger.LogDebug($"{imgEmbed.Image!.Ref} {imgEmbed.Alt}"); + imageEmbeds.Add(imgEmbed); + } + + var postResult = (await this.ATProtocol!.Repo.CreatePostAsync(parsedText?.ModifiedString ?? string.Empty, facets, embed: new ImagesEmbed(imageEmbeds.ToArray()))).HandleResult(); + logger.LogInformation($"Post Created: {postResult.Uri} {postResult.Cid}"); + } + } + + /// + /// Post Command. + /// + [CliCommand(Description = "Post a message")] + public class PostCommand(ILoggerFactory loggerFactory) : CredentialCommandBase(loggerFactory) + { + /// + /// Gets or sets the message to post. + /// + [CliOption(Description = "Text to post")] + public required string Text { get; set; } + + /// + /// Run the command. + /// + /// A representing the asynchronous operation. + public override async Task RunAsync() + { + var logger = loggerFactory.CreateLogger(); + if (string.IsNullOrEmpty(this.Text)) + { + throw new Exception("Text is required."); + } + + var parsedText = MarkdownLinkParser.ParseMarkdown(this.Text); + var facets = MarkdownLinkParser.GenerateFacets(parsedText); + + if (parsedText?.ModifiedString == null) + { + throw new Exception("Failed to parse text."); + } + + await base.RunAsync(); + var postResult = (await this.ATProtocol!.Repo.CreatePostAsync(parsedText.ModifiedString, facets)).HandleResult(); + logger.LogInformation($"Post Created: {postResult.Uri} {postResult.Cid}"); + } + } + + /// + /// Base Credential Command. + /// + public abstract class CredentialCommandBase(ILoggerFactory loggerFactory) + { + /// + /// Gets or sets the Bluesky Username. + /// This property is used to store the username of the user in the Bluesky platform. + /// + [CliOption(Description = "Bluesky Identifier")] + public required string Identifier { get; set; } + + /// + /// Gets or sets the Bluesky App-Password. + /// This property is used to store the password of the user in the Bluesky platform. + /// + [CliOption(Description = "Bluesky App-Password")] + public required string Password { get; set; } + + /// + /// Gets or sets the Bluesky Instance URL. + /// This property is used to store the URL of the Bluesky instance the user wants to connect to. + /// By default, it is set to "bsky.social". + /// + [CliOption(Description = "Bluesky Instance URL.", ValidationRules = CliValidationRules.LegalUri)] + public string Instance { get; set; } = "https://bsky.social"; + + /// + /// Gets or sets the ATProtocol. + /// + public ATProtocol? ATProtocol { get; set; } + + /// + /// Gets or sets the users session information. + /// + public Session? Session { get; set; } + + /// + /// Run the command. + /// + /// A representing the asynchronous operation. + public virtual async Task RunAsync() + { + var logger = loggerFactory.CreateLogger(); + ATProtocolBuilder builder = new ATProtocolBuilder(); + Uri.TryCreate(this.Instance, UriKind.Absolute, out var instance); + if (instance is null) + { + throw new Exception("Invalid URL"); + } + + this.ATProtocol = builder + .WithInstanceUrl(instance) + .Build(); + this.Session = (await this.ATProtocol.Server.CreateSessionAsync(this.Identifier, this.Password)).HandleResult(); + logger.LogInformation($"Authenticated as {this.Session.Handle}."); + logger.LogDebug($"Session Did: {this.Session.Did}"); + } + } + + internal class MarkdownLinkParser + { + /// + /// Generates an array of Facet objects from a ParsedMarkdown object. + /// + /// The ParsedMarkdown object to generate facets from. + /// An array of Facet objects representing the links in the markdown text, or null if the ParsedMarkdown object is null or contains no links. + public static Facet[]? GenerateFacets(ParsedMarkdown? markdown) + { + if (markdown?.Links == null) + { + return null; + } + + var facets = new List(); + foreach (var link in markdown.Links) + { + var index = new FacetIndex(link.NewTextStartIndex, link.NewTextEndIndex + 1); + var facetLink = FacetFeature.CreateLink(link.Url!); + facets.Add(new Facet(index, facetLink)); + } + + return facets.ToArray(); + } + + /// + /// Parses a markdown string and extracts all the links from it. + /// + /// The markdown string to parse. + /// A ParsedMarkdown object containing the original string, the modified string with links replaced by their text, and a list of LinkInfo objects representing each link. + public static ParsedMarkdown? ParseMarkdown(string? input) + { + if (string.IsNullOrEmpty(input)) + { + return null; + } + + string pattern = @"\[(?.+?)\]\((?.+?)\)"; + List links = new List(); + string modifiedString = input; + int offset = 0; + + MatchCollection matches = Regex.Matches(input, pattern); + + foreach (Match match in matches) + { + if (match.Success) + { + string text = match.Groups["text"].Value; + LinkInfo link = new LinkInfo + { + Text = text, + Url = match.Groups["url"].Value, + StartIndex = match.Index, + EndIndex = match.Index + match.Length - 1, + NewTextStartIndex = match.Index - offset, + NewTextEndIndex = match.Index - offset + text.Length - 1, + }; + + links.Add(link); + + // Update the modifiedString by replacing the markdown link with its text + modifiedString = modifiedString.Remove(match.Index - offset, match.Length); + modifiedString = modifiedString.Insert(match.Index - offset, text); + + // Update offset for future calculations + offset += match.Length - text.Length; + } + } + + return new ParsedMarkdown + { + Links = links, + OriginalString = input, + ModifiedString = modifiedString, + }; + } + + /// + /// Represents a link extracted from a markdown text. + /// + public class LinkInfo + { + /// + /// Gets or sets the display text of the link. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the URL of the link. + /// + public string? Url { get; set; } + + /// + /// Gets or sets the start index of the link in the original markdown text. + /// + public int StartIndex { get; set; } + + /// + /// Gets or sets the end index of the link in the original markdown text. + /// + public int EndIndex { get; set; } + + /// + /// Gets or sets the start index of the link in the modified markdown text. + /// + public int NewTextStartIndex { get; set; } + + /// + /// Gets or sets the end index of the link in the modified markdown text. + /// + public int NewTextEndIndex { get; set; } + } + + /// + /// Represents a markdown text that has been parsed for links. + /// + public class ParsedMarkdown + { + /// + /// Gets or sets the list of links extracted from the markdown text. + /// + public List? Links { get; set; } + + /// + /// Gets or sets the original markdown text. + /// + public string? OriginalString { get; set; } + + /// + /// Gets or sets the modified markdown text after link extraction. + /// + public string? ModifiedString { get; set; } + } + } + + private class FileContentTypeDetector + { + private (byte[], string)[] fileSignatures; + private ILogger logger; + + public FileContentTypeDetector(ILogger logger) + { + this.logger = logger; + this.fileSignatures = new[] + { + (new byte[] { 0xFF, 0xD8, 0xFF }, "image/jpeg"), // JPEG + (new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, "image/png"), // PNG + ("GIF8"u8.ToArray(), "image/gif"), // GIF + }; + } + + public string GetContentType(Stream? fileStream) + { + if (fileStream == null || fileStream.Length == 0) + { + return "unsupported"; + } + + long originalPosition = fileStream.Position; + fileStream.Position = 0; + + foreach (var (signature, mimeType) in this.fileSignatures) + { + var buffer = new byte[signature.Length]; + if (fileStream.Read(buffer, 0, signature.Length) == signature.Length) + { + if (signature.SequenceEqual(buffer)) + { + fileStream.Position = originalPosition; + this.logger.LogDebug($"Detected file type: {mimeType}"); + return mimeType; + } + } + + fileStream.Position = 0; + } + + fileStream.Position = originalPosition; + this.logger.LogDebug("Unsupported file type."); + return "unsupported"; + } + } +} diff --git a/apps/bskycli/bskycli.csproj b/apps/bskycli/bskycli.csproj new file mode 100644 index 0000000..b892b79 --- /dev/null +++ b/apps/bskycli/bskycli.csproj @@ -0,0 +1,29 @@ + + + + Exe + net8.0 + enable + enable + true + true + $(NoWarn);SA0001;SA1649 + + + + true + true + + + + + + + + + + + + + + diff --git a/apps/bskycli/bskycli.sln b/apps/bskycli/bskycli.sln new file mode 100644 index 0000000..533a7f3 --- /dev/null +++ b/apps/bskycli/bskycli.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "bskycli", "bskycli.csproj", "{8AC59060-F505-4121-8270-6734DB48ABCA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FishyFlip", "..\..\src\FishyFlip\FishyFlip.csproj", "{C25B0DDC-C09D-4521-9688-12D2FC735879}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8AC59060-F505-4121-8270-6734DB48ABCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AC59060-F505-4121-8270-6734DB48ABCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AC59060-F505-4121-8270-6734DB48ABCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AC59060-F505-4121-8270-6734DB48ABCA}.Release|Any CPU.Build.0 = Release|Any CPU + {C25B0DDC-C09D-4521-9688-12D2FC735879}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C25B0DDC-C09D-4521-9688-12D2FC735879}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C25B0DDC-C09D-4521-9688-12D2FC735879}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C25B0DDC-C09D-4521-9688-12D2FC735879}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal