using Discord.WebSocket;
using NpgsqlTypes;
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Kerobot.Services.EntityCache
{
///
/// Provides and maintains a database-backed cache of users.
/// It is meant to work as an addition to Discord.Net's own user caching capabilities. Its purpose is to
/// provide information on users which the library may not be aware about, such as users no longer in a guild.
///
class UserCache
{
private Kerobot _kb;
internal UserCache(Kerobot kb)
{
_kb = kb;
CreateDatabaseTablesAsync().Wait();
kb.DiscordClient.GuildMemberUpdated += DiscordClient_GuildMemberUpdated;
kb.DiscordClient.UserUpdated += DiscordClient_UserUpdated;
}
#region Database setup
public const string GlobalUserTable = "cache_userdata";
public const string GuildUserTable = "cache_guildmemberdata";
public const string UserView = "cache_guildusers"; // <- intended way to access data
private async Task CreateDatabaseTablesAsync()
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {GlobalUserTable} (" +
"user_id bigint primary key, " +
"cache_update_time timestamptz not null, " + // TODO auto update w/ trigger?
"username text not null, " +
"discriminator text not null, " +
"avatar_url text null" +
")";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {GuildUserTable} (" +
$"user_id bigint references {GlobalUserTable}, " +
"guild_id bigint, " + // TODO foreign key reference?
"first_seen timestamptz not null default NOW(), " + // TODO also make immutable w/ trigger?
"cache_update_time timestamptz not null, " + // TODO auto update w/ trigger?
"nickname text null, " +
"primary key (user_id, guild_id)" +
")";
await c.ExecuteNonQueryAsync();
}
// note to self: https://stackoverflow.com/questions/9556474/how-do-i-automatically-update-a-timestamp-in-postgresql
using (var c = db.CreateCommand())
{
// NOTE: CachedUser constructor is highly dependent of the row order specified here.
// Any changes here must be reflected there.
c.CommandText = $"create or replace view {UserView} as " +
$"select {GlobalUserTable}.user_id, {GuildUserTable}.guild_id, {GuildUserTable}.first_seen, " +
$"{GuildUserTable}.cache_update_time, " +
$"{GlobalUserTable}.username, {GlobalUserTable}.discriminator, {GlobalUserTable}.nickname, " +
$"{GlobalUserTable}.avatar_url " +
$"from {GlobalUserTable} join {GuildUserTable} on {GlobalUserTable}.user_id = {GuildUserTable}.user_id";
await c.ExecuteNonQueryAsync();
// TODO consolidate both tables' cache_update_time, show the greater value.
// Right now we will have just the guild table's data visible.
}
}
}
#endregion
#region Database updates
private async Task DiscordClient_UserUpdated(SocketUser old, SocketUser current)
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {GlobalUserTable} " +
"(user_id, cache_update_time, username, discriminator, avatar_url) values " +
"(@Uid, now(), @Uname, @Disc, @Aurl) " +
"on conflict (user_id) do update " +
"set cache_update_time = EXCLUDED.cache_update_time, username = EXCLUDED.username, " +
"discriminator = EXCLUDED.discriminator, avatar_url = EXCLUDED.avatar_url";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = current.Id;
c.Parameters.Add("@Uname", NpgsqlDbType.Text).Value = current.Username;
c.Parameters.Add("@Disc", NpgsqlDbType.Text).Value = current.Discriminator;
var aurl = c.Parameters.Add("@Aurl", NpgsqlDbType.Text);
var aurlval = current.GetAvatarUrl(Discord.ImageFormat.Png, 1024);
if (aurlval != null) aurl.Value = aurlval;
else aurl.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
private async Task DiscordClient_GuildMemberUpdated(SocketGuildUser old, SocketGuildUser current)
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {GuildUserTable} " +
"(user_id, guild_id, cache_update_time, nickname) values " +
"(@Uid, @Gid, now(), @Nname) " +
"on conflict (user_id) do update " +
"set cache_update_time = EXCLUDED.cache_update_time, username = EXCLUDED.username, " +
"discriminator = EXCLUDED.discriminator, avatar_url = EXCLUDED.avatar_url";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = current.Id;
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = current.Guild.Id;
var nname = c.Parameters.Add("@Nname", NpgsqlDbType.Text);
if (current.Nickname != null) nname.Value = current.Nickname;
else nname.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
#endregion
#region Querying
private static Regex DiscriminatorSearch = new Regex(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled);
///
/// See .
///
internal async Task Query(ulong guildID, string search)
{
// Is search just a number? Assume ID, pass it on to the correct place.
// If it fails, assume the number may be a username.
if (ulong.TryParse(search, out var searchid))
{
var idres = await InternalDoQuery(guildID, searchid, null, null);
if (idres != null) return idres;
}
// Split name/discriminator
string name, disc;
var split = DiscriminatorSearch.Match(search);
if (split.Success)
{
name = split.Groups[1].Value;
disc = split.Groups[2].Value;
}
else
{
name = search;
disc = null;
}
// Strip leading @ from username, if any
if (name.Length > 0 && name[0] == '@') name = name.Substring(1);
// Ready to query
return await InternalDoQuery(guildID, null, name, disc);
// TODO exception handling
}
private async Task InternalDoQuery(ulong guildId, ulong? sID, string sName, string sDisc)
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
var c = db.CreateCommand();
c.CommandText = $"select * from {UserView} " +
"where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guildId;
if (sID.HasValue)
{
c.CommandText += " and user_id = @Uid";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = sID.Value;
}
if (sName != null)
{
c.CommandText += " and username = @Uname";
c.Parameters.Add("@Uname", NpgsqlDbType.Text).Value = sName;
if (sDisc != null) // only search discriminator if name has been provided
{
c.CommandText += " and discriminator = @Udisc";
c.Parameters.Add("@Udisc", NpgsqlDbType.Text).Value = sDisc;
}
}
c.CommandText += " order by cache_update_time desc limit 1";
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync()) return new CachedUser(r);
return null;
}
}
}
#endregion
}
}