diff --git a/Behavior/Behaver.cs b/Behavior/Behaver.cs index e551380..067e9c1 100644 --- a/Behavior/Behaver.cs +++ b/Behavior/Behaver.cs @@ -34,9 +34,6 @@ public class Behaver public async Task ActOn(Message message) { - var permissions = new PermissionSettings(); //TODO: get combined permissions for author and channel - var contentWithoutMention = message.Content; - foreach (var behavior in behaviors) { if (behavior.ShouldAct(message)) @@ -45,7 +42,7 @@ public class Behaver message.ActedOn = true; } } - if (message.ActedOn == false && message.MentionsMe && contentWithoutMention.Contains('?')) + if (message.ActedOn == false && message.MentionsMe && message.Content.Contains('?')) { Console.WriteLine("providing bullshit nonanswer / admitting uselessness"); var responses = new List(){ diff --git a/Behavior/Detiktokify.cs b/Behavior/Detiktokify.cs index c91522c..c0b0450 100644 --- a/Behavior/Detiktokify.cs +++ b/Behavior/Detiktokify.cs @@ -45,12 +45,13 @@ public class Detiktokify : Behavior { foreach(var link in tiktokLinks) { - #pragma warning disable 4014 - message.React("tiktokbad"); - #pragma warning restore 4014 - try { + Console.WriteLine("detiktokifying"); + #pragma warning disable 4014 + await message.React("<:tiktok:1070038619584200884>"); + #pragma warning restore 4014 + var res = await ytdl.RunVideoDownload(link.ToString()); if (!res.Success) { diff --git a/Behavior/Joke.cs b/Behavior/Joke.cs index 7761d48..38a2404 100644 --- a/Behavior/Joke.cs +++ b/Behavior/Joke.cs @@ -37,12 +37,12 @@ public class Joke : Behavior var punchline = thisJoke.Substring(firstIndexAfterQuestionMark, thisJoke.Length - firstIndexAfterQuestionMark); Task.WaitAll(message.Channel.SendMessage(straightline)); Thread.Sleep(TimeSpan.FromSeconds(Shared.r.Next(5, 30))); + //if (Shared.r.Next(8) == 0) + { + LaughAtOwnJoke.punchlinesAwaitingReaction.Add(punchline); + } await message.Channel.SendMessage(punchline); // var myOwnMsg = await message.Channel.SendMessage(punchline); - if (Shared.r.Next(8) == 0) - { - LaughAtOwnJoke.punchlinesAwaitingReaction.Add(punchline); - } }); #pragma warning restore 4014 } diff --git a/Behavior/LaughAtOwnJoke.cs b/Behavior/LaughAtOwnJoke.cs index c2c3d2c..720ea1a 100644 --- a/Behavior/LaughAtOwnJoke.cs +++ b/Behavior/LaughAtOwnJoke.cs @@ -20,9 +20,10 @@ public class LaughAtOwnJoke : Behavior public override bool ShouldAct(Message message) { + //TODO: i need to keep track of myself from here somehow - return false; - //return message.Author == me && punchlinesAwaitingReaction.Contains(message.Content); + //return false; + return /*message.Author == me &&*/ punchlinesAwaitingReaction.Contains(message.Content); } public override async Task ActOn(Message message) diff --git a/DiscordInterface/DiscordInterface.cs b/DiscordInterface/DiscordInterface.cs index 8942968..84a7397 100644 --- a/DiscordInterface/DiscordInterface.cs +++ b/DiscordInterface/DiscordInterface.cs @@ -47,7 +47,7 @@ public class DiscordInterface if (!eventsSignedUp) { eventsSignedUp = true; - Console.WriteLine("Bot is connected! going to sign up for message received and user joined in client ready"); + Console.WriteLine($"Bot is connected {client.CurrentUser.Mention}! going to sign up for message received and user joined in client ready"); client.MessageReceived += MessageReceived; // _client.MessageUpdated += @@ -59,12 +59,12 @@ public class DiscordInterface // _client.GuildMemberUpdated += // _client.UserBanned += // _client.UserLeft += - // _client.ThreadCreated += - // _client.ThreadUpdated += + // _client.ThreadCreated += + // _client.ThreadUpdated += // _client.ThreadDeleted += // _client.JoinedGuild += - // _client.GuildUpdated += - // _client.LeftGuild += + // _client.GuildUpdated += + // _client.LeftGuild += SlashCommandsHelper.Register(client).GetAwaiter().GetResult(); } @@ -77,7 +77,7 @@ public class DiscordInterface await client.LoginAsync(TokenType.Bot, token); await client.StartAsync(); } - + #pragma warning disable 4014 //the "you're not awaiting this" warning. yeah I know, that's the beauty of an async method lol #pragma warning disable 1998 //the "it's async but you're not awaiting anything". private async Task MessageReceived(SocketMessage messageParam) @@ -99,7 +99,7 @@ public class DiscordInterface m.MentionsMe = true; } - if ((suMessage.Author.Id != client.CurrentUser.Id)) +// if ((suMessage.Author.Id != client.CurrentUser.Id)) { if (await Behaver.Instance.ActOn(m)) { @@ -111,7 +111,7 @@ public class DiscordInterface private void UserJoined(SocketGuildUser arg) { - + var guild = UpsertChannel(arg.Guild); var defaultChannel = UpsertChannel(arg.Guild.DefaultChannel); defaultChannel.ParentChannel = guild; @@ -198,6 +198,7 @@ public class DiscordInterface m.ExternalId = dMessage.Id; m.Timestamp = dMessage.EditedTimestamp ?? dMessage.CreatedAt; + if (dMessage.MentionedUserIds?.FirstOrDefault(muid => muid == client.CurrentUser.Id) != null) { m.MentionsMe = true; @@ -208,9 +209,26 @@ public class DiscordInterface } m.Reply = (t) => { return dMessage.ReplyAsync(t); }; - m.React = (e) => { return dMessage.AddReactionAsync(Emote.Parse(e)); }; + m.React = (e) => { return attemptReact(dMessage, e); }; return m; } + + private Task attemptReact(IUserMessage msg, string e) + { + var c = _db.Channels.FirstOrDefault(c => c.ExternalId == msg.Channel.Id); + //var preferredEmote = c.EmoteOverrides?[e] ?? e; //TODO: emote overrides + var preferredEmote = e; + Emote emote; + if(!Emote.TryParse(preferredEmote, out emote)) + { + if(preferredEmote == e) + Console.Error.WriteLine($"never heard of emote {e}"); + return null; + } + + return msg.AddReactionAsync(emote); + } + internal Channel UpsertChannel(IMessageChannel channel) { var addPlease = false; @@ -276,14 +294,14 @@ public class DiscordInterface c.SendFile = (f, t) => { throw new InvalidOperationException($"channel {channel.Name} is guild; send file"); }; return c; } - internal User UpsertUser(IUser user) + internal Account UpsertUser(IUser user) { var addPlease = false; var u = _db.Users.FirstOrDefault(ui => ui.ExternalId == user.Id); if (u == null) { addPlease = true; - u = new User(); + u = new Account(); } u.Username = user.Username; u.ExternalId = user.Id; @@ -302,7 +320,7 @@ public class DiscordInterface //c.Messages = await channel.GetMessagesAsync(); //TODO: this //c.OtherUsers = c.OtherUsers ?? new List(); - //c.OtherUsers = await channel.GetUsersAsync(); + //c.OtherUsers = await channel.GetUsersAsync(); var dChannel = client.GetChannel(channel.ExternalId.Value); if(dChannel is IGuild) { diff --git a/DiscordInterface/SlashCommandsHelper.cs b/DiscordInterface/SlashCommandsHelper.cs index 5b038db..173ddfe 100644 --- a/DiscordInterface/SlashCommandsHelper.cs +++ b/DiscordInterface/SlashCommandsHelper.cs @@ -23,6 +23,7 @@ namespace vassago.DiscordInterface }; public static async Task Register(DiscordSocketClient client) { + return; var commandsInContext = await client.GetGlobalApplicationCommandsAsync(); await Register(client, commandsInContext, null); foreach (var guild in client.Guilds) @@ -110,7 +111,7 @@ namespace vassago.DiscordInterface { public string Id { get; set; } //the date/time you updated yours IN UTC. - public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } public Registration register { get; set; } public ulong? guild { get; set; } public bool alreadyRegistered {get;set; } = false; diff --git a/Migrations/20230619155657_DistinguishUsersAndAccounts.Designer.cs b/Migrations/20230619155657_DistinguishUsersAndAccounts.Designer.cs new file mode 100644 index 0000000..7dba788 --- /dev/null +++ b/Migrations/20230619155657_DistinguishUsersAndAccounts.Designer.cs @@ -0,0 +1,263 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using vassago.Models; + +#nullable disable + +namespace vassago.Migrations +{ + [DbContext(typeof(ChattingContext))] + [Migration("20230619155657_DistinguishUsersAndAccounts")] + partial class DistinguishUsersAndAccounts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("vassago.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("numeric(20,0)"); + + b.Property("IsBot") + .HasColumnType("boolean"); + + b.Property("PermissionTags") + .HasColumnType("integer[]"); + + b.Property("Protocol") + .HasColumnType("text"); + + b.Property("SeenInChannelId") + .HasColumnType("uuid"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SeenInChannelId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("vassago.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("bytea"); + + b.Property("ContentType") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("numeric(20,0)"); + + b.Property("Filename") + .HasColumnType("text"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("vassago.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("numeric(20,0)"); + + b.Property("IsDM") + .HasColumnType("boolean"); + + b.Property("ParentChannelId") + .HasColumnType("uuid"); + + b.Property("PermissionsId") + .HasColumnType("integer"); + + b.Property("Protocol") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentChannelId"); + + b.HasIndex("PermissionsId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("vassago.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActedOn") + .HasColumnType("boolean"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("numeric(20,0)"); + + b.Property("MentionsMe") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ChannelId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("vassago.Models.PermissionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LewdnessFilterLevel") + .HasColumnType("integer"); + + b.Property("LinksAllowed") + .HasColumnType("boolean"); + + b.Property("MaxAttachmentBytes") + .HasColumnType("bigint"); + + b.Property("MaxTextChars") + .HasColumnType("bigint"); + + b.Property("MeannessFilterLevel") + .HasColumnType("integer"); + + b.Property("ReactionsPossible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("PermissionSettings"); + }); + + modelBuilder.Entity("vassago.Models.Account", b => + { + b.HasOne("vassago.Models.Channel", "SeenInChannel") + .WithMany("Users") + .HasForeignKey("SeenInChannelId"); + + b.Navigation("SeenInChannel"); + }); + + modelBuilder.Entity("vassago.Models.Attachment", b => + { + b.HasOne("vassago.Models.Message", "Message") + .WithMany("Attachments") + .HasForeignKey("MessageId"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("vassago.Models.Channel", b => + { + b.HasOne("vassago.Models.Channel", "ParentChannel") + .WithMany("SubChannels") + .HasForeignKey("ParentChannelId"); + + b.HasOne("vassago.Models.PermissionSettings", "Permissions") + .WithMany() + .HasForeignKey("PermissionsId"); + + b.Navigation("ParentChannel"); + + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("vassago.Models.Message", b => + { + b.HasOne("vassago.Models.Account", "Author") + .WithMany() + .HasForeignKey("AuthorId"); + + b.HasOne("vassago.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId"); + + b.Navigation("Author"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("vassago.Models.Channel", b => + { + b.Navigation("Messages"); + + b.Navigation("SubChannels"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("vassago.Models.Message", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20230619155657_DistinguishUsersAndAccounts.cs b/Migrations/20230619155657_DistinguishUsersAndAccounts.cs new file mode 100644 index 0000000..f3f57dc --- /dev/null +++ b/Migrations/20230619155657_DistinguishUsersAndAccounts.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace vassago.Migrations +{ + /// + public partial class DistinguishUsersAndAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisplayName", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "PermissionTags", + table: "Users", + type: "integer[]", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisplayName", + table: "Users"); + + migrationBuilder.DropColumn( + name: "PermissionTags", + table: "Users"); + } + } +} diff --git a/Migrations/ChattingContextModelSnapshot.cs b/Migrations/ChattingContextModelSnapshot.cs index 38f03d6..35bf3c4 100644 --- a/Migrations/ChattingContextModelSnapshot.cs +++ b/Migrations/ChattingContextModelSnapshot.cs @@ -22,6 +22,40 @@ namespace vassago.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("vassago.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("numeric(20,0)"); + + b.Property("IsBot") + .HasColumnType("boolean"); + + b.Property("PermissionTags") + .HasColumnType("integer[]"); + + b.Property("Protocol") + .HasColumnType("text"); + + b.Property("SeenInChannelId") + .HasColumnType("uuid"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SeenInChannelId"); + + b.ToTable("Users"); + }); + modelBuilder.Entity("vassago.Models.Attachment", b => { b.Property("Id") @@ -159,32 +193,13 @@ namespace vassago.Migrations b.ToTable("PermissionSettings"); }); - modelBuilder.Entity("vassago.Models.User", b => + modelBuilder.Entity("vassago.Models.Account", b => { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + b.HasOne("vassago.Models.Channel", "SeenInChannel") + .WithMany("Users") + .HasForeignKey("SeenInChannelId"); - b.Property("ExternalId") - .HasColumnType("numeric(20,0)"); - - b.Property("IsBot") - .HasColumnType("boolean"); - - b.Property("Protocol") - .HasColumnType("text"); - - b.Property("SeenInChannelId") - .HasColumnType("uuid"); - - b.Property("Username") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("SeenInChannelId"); - - b.ToTable("Users"); + b.Navigation("SeenInChannel"); }); modelBuilder.Entity("vassago.Models.Attachment", b => @@ -213,7 +228,7 @@ namespace vassago.Migrations modelBuilder.Entity("vassago.Models.Message", b => { - b.HasOne("vassago.Models.User", "Author") + b.HasOne("vassago.Models.Account", "Author") .WithMany() .HasForeignKey("AuthorId"); @@ -226,20 +241,13 @@ namespace vassago.Migrations b.Navigation("Channel"); }); - modelBuilder.Entity("vassago.Models.User", b => - { - b.HasOne("vassago.Models.Channel", "SeenInChannel") - .WithMany() - .HasForeignKey("SeenInChannelId"); - - b.Navigation("SeenInChannel"); - }); - modelBuilder.Entity("vassago.Models.Channel", b => { b.Navigation("Messages"); b.Navigation("SubChannels"); + + b.Navigation("Users"); }); modelBuilder.Entity("vassago.Models.Message", b => diff --git a/Models/Account.cs b/Models/Account.cs new file mode 100644 index 0000000..124c65a --- /dev/null +++ b/Models/Account.cs @@ -0,0 +1,31 @@ +namespace vassago.Models; + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; + +public class Account +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } + public ulong? ExternalId { get; set; } + public string Username { get; set; } + private string _displayName = null; + public string DisplayName //TODO: fill + { + get + { + return _displayName ?? Username; + } + set + { + _displayName = value; + } + } + public bool IsBot { get; set; } //webhook counts + public Channel SeenInChannel { get; set; } + //permissions are per account-in-channel, and always propagate down. and since protocol will be a channel, I'll set the "is adam" permission on myself 1x/protocol. + public List PermissionTags{get;set;} + public string Protocol { get; set; } +} \ No newline at end of file diff --git a/Models/Channel.cs b/Models/Channel.cs index f2c3475..74b2086 100644 --- a/Models/Channel.cs +++ b/Models/Channel.cs @@ -19,6 +19,8 @@ public class Channel public Channel ParentChannel { get; set; } public string Protocol { get; set; } public List Messages { get; set; } + public List Users { get; set; } + //public Dictionary EmoteOverrides{get;set;} [NonSerialized] public Func SendFile; diff --git a/Models/ChattingContext.cs b/Models/ChattingContext.cs index 4b4d941..d088c7d 100644 --- a/Models/ChattingContext.cs +++ b/Models/ChattingContext.cs @@ -10,7 +10,7 @@ public class ChattingContext : DbContext //public DbSet Emoji {get;set;} public DbSet Messages { get; set; } public DbSet PermissionSettings{get;set;} - public DbSet Users { get; set; } + public DbSet Users { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql(Shared.DBConnectionString) diff --git a/Models/Enums.cs b/Models/Enums.cs index d392ede..394c7a8 100644 --- a/Models/Enums.cs +++ b/Models/Enums.cs @@ -1,33 +1,58 @@ +using System; +using System.ComponentModel; +using System.Reflection; + namespace vassago.Models; public static class Enumerations { - public static string LewdnessFilterLevel(int level) + public enum LewdnessFilterLevel { - switch (level) - { - case 0: - return "this is a christian minecraft server 🙏"; - case 1: - return "G-Rated"; - case 2: - return "polite company"; - case 3: - return ";) ;) ;)"; - default: - return "ERROR"; - } + [Description("this is a christian minecraft server 🙏")] + Strict, + [Description("G-Rated")] + G, + [Description("polite company")] + Moderate, + [Description(";) ;) ;)")] + unrestricted } - public static string MeannessFilterLevel(int level) + public enum MeannessFilterLevel { - switch (level) + [Description("good vibes only")] + Strict, + [Description("387.44 million miles of printed circuits, etc")] + Unrestricted + } + public enum WellknownPermissions + { + Master, //e.g., me. not that I think this would ever be released? + ChannelModerator, + } + + public static string GetDescription(this T enumerationValue) + where T : struct + { + Type type = enumerationValue.GetType(); + if (!type.IsEnum) { - case 0: - return "good vibes only"; - case 1: - return "387.44 million miles of printed circuits, etc"; - default: - return "ERROR"; + throw new ArgumentException("EnumerationValue must be of Enum type", "enumerationValue"); } + + //Tries to find a DescriptionAttribute for a potential friendly name + //for the enum + MemberInfo[] memberInfo = type.GetMember(enumerationValue.ToString()); + if (memberInfo != null && memberInfo.Length > 0) + { + object[] attrs = memberInfo[0].GetCustomAttributes(typeof(DescriptionAttribute), false); + + if (attrs != null && attrs.Length > 0) + { + //Pull out the description value + return ((DescriptionAttribute)attrs[0]).Description; + } + } + //If we have no description attribute, just return the ToString of the enum + return enumerationValue.ToString(); } } \ No newline at end of file diff --git a/Models/Message.cs b/Models/Message.cs index bc64030..1ff1ae3 100644 --- a/Models/Message.cs +++ b/Models/Message.cs @@ -14,18 +14,18 @@ public class Message public ulong? ExternalId { get; set; } public string Content { get; set; } /* - * TODO: more general "talking to me". current impl is platform's capital m Mention, but I'd like it if they use my name without "properly" + * TODO: more general "talking to me". current impl is platform's capital m Mention, but I'd like it if they use my name without "properly" * mentioning me, and also if it's just me and them in a channel */ public bool MentionsMe { get; set; } public DateTimeOffset Timestamp { get; set; } public bool ActedOn { get; set; } public List Attachments { get; set; } - public User Author { get; set; } + public Account Author { get; set; } public Channel Channel { get; set; } - + [NonSerialized] public Func Reply; diff --git a/Models/User.cs b/Models/User.cs index cdb4158..b2c93e6 100644 --- a/Models/User.cs +++ b/Models/User.cs @@ -5,13 +5,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Reflection; -public class User //TODO: distinguish the user and their account +public class User { [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; set; } - public ulong? ExternalId { get; set; } - public string Username { get; set; } //TODO: display names. many protocols support this feature. - public bool IsBot { get; set; } //webhook counts - public Channel SeenInChannel { get; set; } - public string Protocol { get; set; } + public List Accounts { get; set; } } \ No newline at end of file