Polls persist restarts now.

This commit is contained in:
Master Kwoth 2017-10-27 18:39:56 +02:00
parent 2fbb80a2a2
commit 29f97f3732
16 changed files with 2461 additions and 168 deletions

View File

@ -10,6 +10,10 @@ namespace NadekoBot.Common.Collections
public List<T> Source { get; } public List<T> Source { get; }
private readonly object _locker = new object(); private readonly object _locker = new object();
public IndexedCollection()
{
Source = new List<T>();
}
public IndexedCollection(IEnumerable<T> source) public IndexedCollection(IEnumerable<T> source)
{ {
lock (_locker) lock (_locker)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using System.Collections.Generic;
namespace NadekoBot.Migrations
{
public partial class pollrewrite : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Poll",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ChannelId = table.Column<ulong>(type: "INTEGER", nullable: false),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
Question = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Poll", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PollAnswer",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true),
Index = table.Column<int>(type: "INTEGER", nullable: false),
PollId = table.Column<int>(type: "INTEGER", nullable: true),
Text = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PollAnswer", x => x.Id);
table.ForeignKey(
name: "FK_PollAnswer_Poll_PollId",
column: x => x.PollId,
principalTable: "Poll",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "PollVote",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true),
PollId = table.Column<int>(type: "INTEGER", nullable: true),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
VoteIndex = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PollVote", x => x.Id);
table.ForeignKey(
name: "FK_PollVote_Poll_PollId",
column: x => x.PollId,
principalTable: "Poll",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Poll_GuildId",
table: "Poll",
column: "GuildId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PollAnswer_PollId",
table: "PollAnswer",
column: "PollId");
migrationBuilder.CreateIndex(
name: "IX_PollVote_PollId",
table: "PollVote",
column: "PollId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PollAnswer");
migrationBuilder.DropTable(
name: "PollVote");
migrationBuilder.DropTable(
name: "Poll");
}
}
}

View File

@ -983,6 +983,67 @@ namespace NadekoBot.Migrations
b.ToTable("PlaylistSong"); b.ToTable("PlaylistSong");
}); });
modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.Poll", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd();
b.Property<ulong>("ChannelId");
b.Property<DateTime?>("DateAdded");
b.Property<ulong>("GuildId");
b.Property<string>("Question");
b.HasKey("Id");
b.HasIndex("GuildId")
.IsUnique();
b.ToTable("Poll");
});
modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.PollAnswer", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd();
b.Property<DateTime?>("DateAdded");
b.Property<int>("Index");
b.Property<int?>("PollId");
b.Property<string>("Text");
b.HasKey("Id");
b.HasIndex("PollId");
b.ToTable("PollAnswer");
});
modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.PollVote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd();
b.Property<DateTime?>("DateAdded");
b.Property<int?>("PollId");
b.Property<ulong>("UserId");
b.Property<int>("VoteIndex");
b.HasKey("Id");
b.HasIndex("PollId");
b.ToTable("PollVote");
});
modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.Quote", b => modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.Quote", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1771,6 +1832,20 @@ namespace NadekoBot.Migrations
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.PollAnswer", b =>
{
b.HasOne("NadekoBot.Core.Services.Database.Models.Poll")
.WithMany("Answers")
.HasForeignKey("PollId");
});
modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.PollVote", b =>
{
b.HasOne("NadekoBot.Core.Services.Database.Models.Poll")
.WithMany("Votes")
.HasForeignKey("PollId");
});
modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.RaceAnimal", b => modelBuilder.Entity("NadekoBot.Core.Services.Database.Models.RaceAnimal", b =>
{ {
b.HasOne("NadekoBot.Core.Services.Database.Models.BotConfig") b.HasOne("NadekoBot.Core.Services.Database.Models.BotConfig")

View File

@ -1,129 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Discord;
using Discord.WebSocket;
using NadekoBot.Extensions;
using NadekoBot.Core.Services.Impl;
namespace NadekoBot.Modules.Games.Common
{
public class Poll
{
private readonly IUserMessage _originalMessage;
private readonly IGuild _guild;
private readonly string[] answers;
private readonly ConcurrentDictionary<ulong, int> _participants = new ConcurrentDictionary<ulong, int>();
private readonly string _question;
private readonly DiscordSocketClient _client;
private readonly NadekoStrings _strings;
private bool running = false;
public event Action<ulong> OnEnded = delegate { };
public Poll(DiscordSocketClient client, NadekoStrings strings, IUserMessage umsg, string question, IEnumerable<string> enumerable)
{
_client = client;
_strings = strings;
_originalMessage = umsg;
_guild = ((ITextChannel)umsg.Channel).Guild;
_question = question;
answers = enumerable as string[] ?? enumerable.ToArray();
}
public EmbedBuilder GetStats(string title)
{
var results = _participants.GroupBy(kvp => kvp.Value)
.ToDictionary(x => x.Key, x => x.Sum(kvp => 1))
.OrderByDescending(kvp => kvp.Value)
.ToArray();
var eb = new EmbedBuilder().WithTitle(title);
var sb = new StringBuilder()
.AppendLine(Format.Bold(_question))
.AppendLine();
var totalVotesCast = 0;
if (results.Length == 0)
{
sb.AppendLine(GetText("no_votes_cast"));
}
else
{
for (int i = 0; i < results.Length; i++)
{
var result = results[i];
sb.AppendLine(GetText("poll_result",
result.Key,
Format.Bold(answers[result.Key - 1]),
Format.Bold(result.Value.ToString())));
totalVotesCast += result.Value;
}
}
eb.WithDescription(sb.ToString())
.WithFooter(efb => efb.WithText(GetText("x_votes_cast", totalVotesCast)));
return eb;
}
public async Task StartPoll()
{
var msgToSend = GetText("poll_created", Format.Bold(_originalMessage.Author.Username)) + "\n\n" + Format.Bold(_question) + "\n";
var num = 1;
msgToSend = answers.Aggregate(msgToSend, (current, answ) => current + $"`{num++}.` **{answ}**\n");
msgToSend += "\n" + Format.Bold(GetText("poll_vote_public"));
await _originalMessage.Channel.SendConfirmAsync(msgToSend).ConfigureAwait(false);
running = true;
}
public async Task StopPoll()
{
running = false;
OnEnded(_guild.Id);
await _originalMessage.Channel.EmbedAsync(GetStats("POLL CLOSED")).ConfigureAwait(false);
}
public async Task<bool> TryVote(IUserMessage msg)
{
// has to be a user message
if (msg == null || msg.Author.IsBot || !running)
return false;
// has to be an integer
if (!int.TryParse(msg.Content, out int vote))
return false;
if (vote < 1 || vote > answers.Length)
return false;
IMessageChannel ch;
//if public, channel must be the same the poll started in
if (_originalMessage.Channel.Id != msg.Channel.Id)
return false;
ch = msg.Channel;
//user can vote only once
if (_participants.TryAdd(msg.Author.Id, vote))
{
var toDelete = await ch.SendConfirmAsync(GetText("poll_voted", Format.Bold(msg.Author.ToString()))).ConfigureAwait(false);
toDelete.DeleteAfter(5);
try { await msg.DeleteAsync().ConfigureAwait(false); } catch { }
return true;
}
return false;
}
private string GetText(string key, params object[] replacements)
=> _strings.GetText(key,
_guild.Id,
"Games".ToLowerInvariant(),
replacements);
}
}

View File

@ -0,0 +1,72 @@
using System.Threading.Tasks;
using Discord;
using NadekoBot.Core.Services.Database.Models;
using NadekoBot.Core.Services;
using System;
using System.Threading;
namespace NadekoBot.Modules.Games.Common
{
public class PollRunner
{
public Poll Poll { get; }
private readonly DbService _db;
public event Func<IUserMessage, IGuildUser, Task> OnVoted;
private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1);
public PollRunner(DbService db, Poll poll)
{
_db = db;
Poll = poll;
}
public async Task<bool> TryVote(IUserMessage msg)
{
PollVote voteObj;
await _locker.WaitAsync().ConfigureAwait(false);
try
{
// has to be a user message
// channel must be the same the poll started in
if (msg == null || msg.Author.IsBot || msg.Channel.Id != Poll.ChannelId)
return false;
// has to be an integer
if (!int.TryParse(msg.Content, out int vote))
return false;
--vote;
if (vote < 0 || vote >= Poll.Answers.Count)
return false;
var usr = msg.Author as IGuildUser;
if (usr == null)
return false;
voteObj = new PollVote()
{
UserId = msg.Author.Id,
VoteIndex = vote,
};
if (!Poll.Votes.Add(voteObj))
return false;
var _ = OnVoted?.Invoke(msg, usr);
}
finally { _locker.Release(); }
using (var uow = _db.UnitOfWork)
{
var trackedPoll = uow.Polls.Get(Poll.Id);
trackedPoll.Votes.Add(voteObj);
uow.Complete();
}
return true;
}
public void End()
{
OnVoted = null;
}
}
}

View File

@ -5,6 +5,9 @@ using NadekoBot.Extensions;
using System.Threading.Tasks; using System.Threading.Tasks;
using NadekoBot.Common.Attributes; using NadekoBot.Common.Attributes;
using NadekoBot.Modules.Games.Services; using NadekoBot.Modules.Games.Services;
using NadekoBot.Core.Services.Database.Models;
using System.Text;
using System.Linq;
namespace NadekoBot.Modules.Games namespace NadekoBot.Modules.Games
{ {
@ -23,24 +26,33 @@ namespace NadekoBot.Modules.Games
[NadekoCommand, Usage, Description, Aliases] [NadekoCommand, Usage, Description, Aliases]
[RequireUserPermission(GuildPermission.ManageMessages)] [RequireUserPermission(GuildPermission.ManageMessages)]
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
public Task Poll([Remainder] string arg = null) public async Task Poll([Remainder] string arg)
=> InternalStartPoll(arg); {
if (string.IsNullOrWhiteSpace(arg))
return;
var poll = _service.CreatePoll(Context.Guild.Id,
Context.Channel.Id, arg);
if (_service.StartPoll(poll))
await Context.Channel
.EmbedAsync(new EmbedBuilder()
.WithTitle(GetText("poll_created", Context.User.ToString()))
.WithDescription(string.Join("\n", poll.Answers
.Select(x => $"`{x.Index + 1}.` {Format.Bold(x.Text)}"))))
.ConfigureAwait(false);
else
await ReplyErrorLocalized("poll_already_running").ConfigureAwait(false);
}
[NadekoCommand, Usage, Description, Aliases] [NadekoCommand, Usage, Description, Aliases]
[RequireUserPermission(GuildPermission.ManageMessages)] [RequireUserPermission(GuildPermission.ManageMessages)]
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
public async Task PollStats() public async Task PollStats()
{ {
if (!_service.ActivePolls.TryGetValue(Context.Guild.Id, out var poll)) if (!_service.ActivePolls.TryGetValue(Context.Guild.Id, out var pr))
return; return;
await Context.Channel.EmbedAsync(poll.GetStats(GetText("current_poll_results"))); await Context.Channel.EmbedAsync(GetStats(pr.Poll, GetText("current_poll_results")));
}
private async Task InternalStartPoll(string arg)
{
if(await _service.StartPoll(Context.Guild.Id, Context.Message, arg) == false)
await ReplyErrorLocalized("poll_already_running").ConfigureAwait(false);
} }
[NadekoCommand, Usage, Description, Aliases] [NadekoCommand, Usage, Description, Aliases]
@ -50,9 +62,50 @@ namespace NadekoBot.Modules.Games
{ {
var channel = (ITextChannel)Context.Channel; var channel = (ITextChannel)Context.Channel;
if(_service.ActivePolls.TryRemove(channel.Guild.Id, out var poll)) Poll p;
await poll.StopPoll().ConfigureAwait(false); if ((p = _service.StopPoll(Context.Guild.Id)) == null)
return;
var embed = GetStats(p, GetText("poll_closed"));
await Context.Channel.EmbedAsync(embed)
.ConfigureAwait(false);
} }
}
public EmbedBuilder GetStats(Poll poll, string title)
{
var results = poll.Votes.GroupBy(kvp => kvp.VoteIndex)
.ToDictionary(x => x.Key, x => x.Sum(kvp => 1))
.OrderByDescending(kvp => kvp.Value)
.ToArray();
var eb = new EmbedBuilder().WithTitle(title);
var sb = new StringBuilder()
.AppendLine(Format.Bold(poll.Question))
.AppendLine();
var totalVotesCast = 0;
if (results.Length == 0)
{
sb.AppendLine(GetText("no_votes_cast"));
}
else
{
for (int i = 0; i < results.Length; i++)
{
var result = results[i];
sb.AppendLine(GetText("poll_result",
result.Key + 1,
Format.Bold(poll.Answers[result.Key].Text),
Format.Bold(result.Value.ToString())));
totalVotesCast += result.Value;
}
}
return eb.WithDescription(sb.ToString())
.WithFooter(efb => efb.WithText(GetText("x_votes_cast", totalVotesCast)))
.WithOkColor();
}
}
} }
} }

View File

@ -9,45 +9,102 @@ using NadekoBot.Modules.Games.Common;
using NadekoBot.Core.Services; using NadekoBot.Core.Services;
using NadekoBot.Core.Services.Impl; using NadekoBot.Core.Services.Impl;
using NLog; using NLog;
using NadekoBot.Core.Services.Database.Models;
using NadekoBot.Common.Collections;
using NadekoBot.Extensions;
using NadekoBot.Core.Services.Database;
namespace NadekoBot.Modules.Games.Services namespace NadekoBot.Modules.Games.Services
{ {
public class PollService : IEarlyBlockingExecutor, INService public class PollService : IEarlyBlockingExecutor, INService
{ {
public ConcurrentDictionary<ulong, Poll> ActivePolls = new ConcurrentDictionary<ulong, Poll>(); public ConcurrentDictionary<ulong, PollRunner> ActivePolls { get; } = new ConcurrentDictionary<ulong, PollRunner>();
private readonly Logger _log; private readonly Logger _log;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly NadekoStrings _strings; private readonly NadekoStrings _strings;
private readonly DbService _db;
private readonly NadekoStrings _strs;
public PollService(DiscordSocketClient client, NadekoStrings strings) public PollService(DiscordSocketClient client, NadekoStrings strings, DbService db,
NadekoStrings strs, IUnitOfWork uow)
{ {
_log = LogManager.GetCurrentClassLogger(); _log = LogManager.GetCurrentClassLogger();
_client = client; _client = client;
_strings = strings; _strings = strings;
_db = db;
_strs = strs;
ActivePolls = uow.Polls.GetAllPolls()
.ToDictionary(x => x.GuildId, x =>
{
var pr = new PollRunner(db, x);
pr.OnVoted += Pr_OnVoted;
return pr;
})
.ToConcurrent();
} }
public async Task<bool?> StartPoll(ulong guildId, IUserMessage msg, string arg) public Poll CreatePoll(ulong guildId, ulong channelId, string input)
{ {
if (string.IsNullOrWhiteSpace(arg) || !arg.Contains(";")) if (string.IsNullOrWhiteSpace(input) || !input.Contains(";"))
return null; return null;
var data = arg.Split(';'); var data = input.Split(';');
if (data.Length < 3) if (data.Length < 3)
return null; return null;
var poll = new Poll(_client, _strings, msg, data[0], data.Skip(1)); var col = new IndexedCollection<PollAnswer>(data.Skip(1)
if (ActivePolls.TryAdd(guildId, poll)) .Select(x => new PollAnswer() { Text = x }));
{
poll.OnEnded += (gid) =>
{
ActivePolls.TryRemove(gid, out _);
};
await poll.StartPoll().ConfigureAwait(false); return new Poll()
{
Answers = col,
Question = data[0],
ChannelId = channelId,
GuildId = guildId,
Votes = new System.Collections.Generic.HashSet<PollVote>()
};
}
public bool StartPoll(Poll p)
{
var pr = new PollRunner(_db, p);
if (ActivePolls.TryAdd(p.GuildId, pr))
{
using (var uow = _db.UnitOfWork)
{
uow.Polls.Add(p);
uow.Complete();
}
pr.OnVoted += Pr_OnVoted;
return true; return true;
} }
return false; return false;
} }
public Poll StopPoll(ulong guildId)
{
if (ActivePolls.TryRemove(guildId, out var pr))
{
pr.OnVoted -= Pr_OnVoted;
using (var uow = _db.UnitOfWork)
{
uow.Polls.RemovePoll(pr.Poll.Id);
uow.Complete();
}
return pr.Poll;
}
return null;
}
private async Task Pr_OnVoted(IUserMessage msg, IGuildUser usr)
{
var toDelete = await msg.Channel.SendConfirmAsync(_strs.GetText("poll_voted", usr.Guild.Id, "Games".ToLowerInvariant(), Format.Bold(usr.ToString())))
.ConfigureAwait(false);
toDelete.DeleteAfter(5);
try { await msg.DeleteAsync().ConfigureAwait(false); } catch { }
}
public async Task<bool> TryExecuteEarly(DiscordSocketClient client, IGuild guild, IUserMessage msg) public async Task<bool> TryExecuteEarly(DiscordSocketClient client, IGuild guild, IUserMessage msg)
{ {
if (guild == null) if (guild == null)

View File

@ -24,6 +24,7 @@ namespace NadekoBot.Core.Services.Database
IWarningsRepository Warnings { get; } IWarningsRepository Warnings { get; }
IXpRepository Xp { get; } IXpRepository Xp { get; }
IClubRepository Clubs { get; } IClubRepository Clubs { get; }
IPollsRepository Polls { get; }
int Complete(); int Complete();
Task<int> CompleteAsync(); Task<int> CompleteAsync();

View File

@ -3,15 +3,16 @@ using System.Collections.Generic;
namespace NadekoBot.Core.Services.Database.Models namespace NadekoBot.Core.Services.Database.Models
{ {
public class Poll public class Poll : DbEntity
{ {
public ulong GuildId { get; set; } public ulong GuildId { get; set; }
public ulong ChannelId { get; set; }
public string Question { get; set; } public string Question { get; set; }
public IndexedCollection<PollAnswer> Answers { get; set; } public IndexedCollection<PollAnswer> Answers { get; set; }
public HashSet<PollVote> Votes { get; set; } public HashSet<PollVote> Votes { get; set; } = new HashSet<PollVote>();
} }
public class PollAnswer : IIndexed public class PollAnswer : DbEntity, IIndexed
{ {
public int Index { get; set; } public int Index { get; set; }
public string Text { get; set; } public string Text { get; set; }

View File

@ -1,13 +1,20 @@
using System; namespace NadekoBot.Core.Services.Database.Models
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NadekoBot.Core.Services.Database.Models
{ {
public class PollVote public class PollVote : DbEntity
{ {
public ulong UserId { get; set; }
public int VoteIndex { get; set; }
public override int GetHashCode()
{
return UserId.GetHashCode();
}
public override bool Equals(object obj)
{
return obj is PollVote p
? p.UserId == UserId
: false;
}
} }
} }

View File

@ -341,6 +341,12 @@ namespace NadekoBot.Core.Services.Database
.WithMany(x => x.Bans); .WithMany(x => x.Bans);
#endregion #endregion
#region Polls
modelBuilder.Entity<Poll>()
.HasIndex(x => x.GuildId)
.IsUnique();
#endregion
} }
} }
} }

View File

@ -0,0 +1,15 @@
using NadekoBot.Core.Services.Database.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NadekoBot.Core.Services.Database.Repositories
{
public interface IPollsRepository : IRepository<Poll>
{
IEnumerable<Poll> GetAllPolls();
void RemovePoll(int id);
}
}

View File

@ -0,0 +1,35 @@
using NadekoBot.Core.Services.Database.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace NadekoBot.Core.Services.Database.Repositories.Impl
{
public class PollsRepository : Repository<Poll>, IPollsRepository
{
public PollsRepository(DbContext context) : base(context)
{
}
public IEnumerable<Poll> GetAllPolls()
{
return _set.Include(x => x.Answers)
.Include(x => x.Votes)
.ToArray();
}
public void RemovePoll(int id)
{
var p = _set
.Include(x => x.Answers)
.Include(x => x.Votes)
.FirstOrDefault(x => x.Id == id);
p.Votes.Clear();
p.Answers.Clear();
_set.Remove(p);
}
}
}

View File

@ -57,6 +57,9 @@ namespace NadekoBot.Core.Services.Database
private IClubRepository _clubs; private IClubRepository _clubs;
public IClubRepository Clubs => _clubs ?? (_clubs = new ClubRepository(_context)); public IClubRepository Clubs => _clubs ?? (_clubs = new ClubRepository(_context));
private IPollsRepository _polls;
public IPollsRepository Polls => _polls ?? (_polls = new PollsRepository(_context));
public UnitOfWork(NadekoContext context) public UnitOfWork(NadekoContext context)
{ {
_context = context; _context = context;

View File

@ -713,7 +713,8 @@
"games_current_poll_results": "Current poll results", "games_current_poll_results": "Current poll results",
"games_no_votes_cast": "No votes cast.", "games_no_votes_cast": "No votes cast.",
"games_poll_already_running": "Poll is already running on this server.", "games_poll_already_running": "Poll is already running on this server.",
"games_poll_created": "📃 {0} has created a poll which requires your attention:", "games_poll_created": "📃 {0} has created a poll",
"games_poll_closed": "Poll Closed!",
"games_poll_result": "`{0}.` {1} with {2} votes.", "games_poll_result": "`{0}.` {1} with {2} votes.",
"games_poll_voted": "{0} voted.", "games_poll_voted": "{0} voted.",
"games_poll_vote_private": "Private Message me with the corresponding number of the answer.", "games_poll_vote_private": "Private Message me with the corresponding number of the answer.",