tgstation-server
The /tg/station 13 server suite
IrcProvider.cs
Go to the documentation of this file.
1 using Meebey.SmartIrc4net;
2 using Microsoft.Extensions.Logging;
3 using System;
4 using System.Collections.Generic;
5 using System.Globalization;
6 using System.Linq;
7 using System.Text;
8 using System.Threading;
9 using System.Threading.Tasks;
12 
13 namespace Tgstation.Server.Host.Components.Chat.Providers
14 {
18  sealed class IrcProvider : Provider
19  {
20  const int TimeoutSeconds = 5;
21 
23  public override bool Connected => client.IsConnected;
24 
26  public override string BotMention => client.Nickname;
27 
32 
36  readonly ILogger<IrcProvider> logger;
37 
41  readonly IrcFeatures client;
42 
46  readonly string address;
50  readonly ushort port;
54  readonly string nickname;
58  readonly string password;
63 
67  readonly Dictionary<ulong, string> channelIdMap;
68 
72  readonly Dictionary<ulong, string> queryChannelIdMap;
73 
78 
82  Task listenTask;
83 
88 
101  public IrcProvider(IApplication application, IAsyncDelayer asyncDelayer, ILogger<IrcProvider> logger, string address, ushort port, string nickname, string password, IrcPasswordType? passwordType, bool useSsl)
102  {
103  if (application == null)
104  throw new ArgumentNullException(nameof(application));
105  this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
106  this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
107 
108  this.address = address ?? throw new ArgumentNullException(nameof(address));
109  this.port = port;
110  this.nickname = nickname ?? throw new ArgumentNullException(nameof(nickname));
111 
112  if (passwordType.HasValue && password == null)
113  throw new ArgumentNullException(nameof(password));
114 
115  if (password != null && !passwordType.HasValue)
116  throw new ArgumentNullException(nameof(passwordType));
117 
118  this.password = password;
119  this.passwordType = passwordType;
120 
121  client = new IrcFeatures
122  {
123  SupportNonRfc = true,
124  CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...",
125  AutoRejoin = true,
126  AutoRejoinOnKick = true,
127  AutoRelogin = true,
128  AutoRetry = true,
129  AutoRetryLimit = TimeoutSeconds,
130  AutoRetryDelay = TimeoutSeconds,
131  ActiveChannelSyncing = true,
132  AutoNickHandling = true,
133  CtcpVersion = application.VersionString,
134  UseSsl = useSsl
135  };
136  if (useSsl)
137  client.ValidateServerCertificate = true; //dunno if it defaults to that or what
138 
139  client.OnChannelMessage += Client_OnChannelMessage;
140  client.OnQueryMessage += Client_OnQueryMessage;
141 
142  channelIdMap = new Dictionary<ulong, string>();
143  queryChannelIdMap = new Dictionary<ulong, string>();
144  channelIdCounter = 1;
145  disconnecting = false;
146  }
147 
149  public override void Dispose()
150  {
151  if (Connected)
152  {
153  disconnecting = true;
154  client.Disconnect(); //just closes the socket
155  }
156  }
157 
163  void HandleMessage(IrcEventArgs e, bool isPrivate)
164  {
165  if (e.Data.Nick.ToUpperInvariant() == client.Nickname.ToUpperInvariant())
166  return;
167 
168  var username = e.Data.Nick;
169  var channelName = isPrivate ? username : e.Data.Channel;
170 
171  ulong MapAndGetChannelId(Dictionary<ulong, string> dicToCheck)
172  {
173  ulong? resultId = null;
174  if (!dicToCheck.Any(x =>
175  {
176  if (x.Value != channelName)
177  return false;
178  resultId = x.Key;
179  return true;
180  }))
181  {
182  resultId = channelIdCounter++;
183  dicToCheck.Add(resultId.Value, channelName);
184  if (dicToCheck == queryChannelIdMap)
185  channelIdMap.Add(resultId.Value, null);
186  }
187  return resultId.Value;
188  };
189 
190  ulong userId, channelId;
191  lock (this)
192  {
193  userId = MapAndGetChannelId(queryChannelIdMap);
194  channelId = isPrivate ? userId : MapAndGetChannelId(channelIdMap);
195  }
196 
197  var message = new Message
198  {
199  Content = e.Data.Message,
200  User = new User
201  {
202  Channel = new Channel
203  {
204  ConnectionName = address,
205  FriendlyName = isPrivate ? String.Format(CultureInfo.InvariantCulture, "PM: {0}", channelName) : channelName,
206  RealId = channelId,
207  IsPrivateChannel = isPrivate
208  //isAdmin and Tag populated by manager
209  },
210  FriendlyName = username,
211  RealId = userId,
212  Mention = username
213  }
214  };
215 
216  EnqueueMessage(message);
217  }
218 
224  void Client_OnQueryMessage(object sender, IrcEventArgs e) => HandleMessage(e, true);
225 
231  void Client_OnChannelMessage(object sender, IrcEventArgs e) => HandleMessage(e, false);
232 
234  public override Task<bool> Connect(CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
235  {
236  disconnecting = false;
237  lock (this)
238  try
239  {
240  client.Connect(address, port);
241 
242  cancellationToken.ThrowIfCancellationRequested();
243 
244  if (passwordType == IrcPasswordType.Server)
245  client.Login(nickname, nickname, 0, nickname, password);
246  else
247  {
248  if (passwordType == IrcPasswordType.Sasl)
249  {
250  client.WriteLine("CAP REQ :sasl", Priority.Critical); //needs to be put in the buffer before anything else
251  cancellationToken.ThrowIfCancellationRequested();
252  }
253  client.Login(nickname, nickname, 0, nickname);
254  }
255 
256  if (passwordType == IrcPasswordType.NickServ)
257  {
258  cancellationToken.ThrowIfCancellationRequested();
259  client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password));
260  }
261  else if (passwordType == IrcPasswordType.Sasl)
262  {
263  //wait for the sasl ack or timeout
264  var recievedAck = false;
265  var recievedPlus = false;
266  client.OnReadLine += (sender, e) =>
267  {
268  if (e.Line.Contains("ACK :sasl", StringComparison.Ordinal))
269  recievedAck = true;
270  else if (e.Line.Contains("AUTHENTICATE +", StringComparison.Ordinal))
271  recievedPlus = true;
272  };
273 
274  var startTime = DateTimeOffset.Now;
275  var endTime = DateTimeOffset.Now.AddSeconds(TimeoutSeconds);
276  cancellationToken.ThrowIfCancellationRequested();
277 
278  var listenTimeSpan = TimeSpan.FromMilliseconds(10);
279  for (; !recievedAck && DateTimeOffset.Now <= endTime; asyncDelayer.Delay(listenTimeSpan, cancellationToken).GetAwaiter().GetResult())
280  client.Listen(false);
281 
282  client.WriteLine("AUTHENTICATE PLAIN", Priority.Critical);
283  cancellationToken.ThrowIfCancellationRequested();
284 
285  for (; !recievedPlus && DateTimeOffset.Now <= endTime; asyncDelayer.Delay(listenTimeSpan, cancellationToken).GetAwaiter().GetResult())
286  client.Listen(false);
287 
288  //Stolen! https://github.com/znc/znc/blob/1e697580155d5a38f8b5a377f3b1d94aaa979539/modules/sasl.cpp#L196
289  var authString = String.Format(CultureInfo.InvariantCulture, "{0}{1}{0}{1}{2}", nickname, '\0', password);
290  var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString));
291  var authLine = String.Format(CultureInfo.InvariantCulture, "AUTHENTICATE {0}", b64);
292  var chars = authLine.ToCharArray();
293  client.WriteLine(authLine, Priority.Critical);
294 
295  cancellationToken.ThrowIfCancellationRequested();
296  client.WriteLine("CAP END", Priority.Critical);
297  }
298 
299  client.Listen(false);
300 
301  listenTask = Task.Factory.StartNew(() =>
302  {
303  while (!disconnecting && client.IsConnected && client.Nickname != nickname)
304  {
305  client.ListenOnce(true);
306  if (disconnecting || !client.IsConnected)
307  break;
308  client.Listen(false);
309  //ensure we have the correct nick
310  if (client.GetIrcUser(nickname) == null)
311  client.RfcNick(nickname);
312  }
313  client.Listen();
314  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
315  }
316  catch (OperationCanceledException)
317  {
318  throw;
319  }
320  catch (Exception e)
321  {
322  logger.LogWarning("Unable to connect to IRC: {0}", e);
323  return false;
324  }
325  return true;
326  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
327 
329  public override async Task Disconnect(CancellationToken cancellationToken)
330  {
331  if (!Connected)
332  return;
333  try
334  {
335  await Task.Factory.StartNew(() =>
336  {
337  try
338  {
339  client.RfcQuit("Mr. Stark, I don't feel so good...", Priority.Critical); //priocritical otherwise it wont go through
340  }
341  catch (Exception e)
342  {
343  logger.LogWarning("Error quitting IRC: {0}", e);
344  }
345  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
346  Dispose();
347  await listenTask.ConfigureAwait(false);
348  }
349  catch (OperationCanceledException)
350  {
351  throw;
352  }
353  catch (Exception e)
354  {
355  logger.LogWarning("Error disconnecting from IRC! Exception: {0}", e);
356  }
357  }
358 
360  public override Task<IReadOnlyList<Channel>> MapChannels(IEnumerable<ChatChannel> channels, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
361  {
362  if (channels.Any(x => x.IrcChannel == null))
363  throw new InvalidOperationException("ChatChannel missing IrcChannel!");
364  lock (this)
365  {
366  var hs = new HashSet<string>(); //for unique inserts
367  foreach (var I in channels)
368  hs.Add(I.IrcChannel);
369  var toPart = new List<string>();
370  foreach (var I in client.JoinedChannels)
371  if (!hs.Remove(I))
372  toPart.Add(I);
373 
374  foreach (var I in toPart)
375  client.RfcPart(I, "Pretty nice abscond!");
376  foreach (var I in hs)
377  client.RfcJoin(I);
378 
379  return (IReadOnlyList<Channel>)channels.Select(x =>
380  {
381  ulong? id = null;
382  if (!channelIdMap.Any(y =>
383  {
384  if (y.Value != x.IrcChannel)
385  return false;
386  id = y.Key;
387  return true;
388  }))
389  {
390  id = channelIdCounter++;
391  channelIdMap.Add(id.Value, x.IrcChannel);
392  }
393  return new Channel
394  {
395  RealId = id.Value,
396  IsAdminChannel = x.IsAdminChannel == true,
397  ConnectionName = address,
398  FriendlyName = channelIdMap[id.Value],
399  IsPrivateChannel = false,
400  Tag = x.Tag
401  };
402  }).ToList();
403  }
404  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
405 
407  public override Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
408  {
409  var channelName = channelIdMap[channelId];
410  SendType sendType;
411  if (channelName == null)
412  {
413  channelName = queryChannelIdMap[channelId];
414  sendType = SendType.Notice;
415  }
416  else
417  sendType = SendType.Message;
418  try
419  {
420  client.SendMessage(sendType, channelName, message);
421  }
422  catch (Exception e)
423  {
424  logger.LogWarning("Unable to send to channel: {0}", e);
425  }
426  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
427  }
428 }
ulong channelIdCounter
Id counter for channelIdMap
Definition: IrcProvider.cs:77
readonly Dictionary< ulong, string > queryChannelIdMap
Map of Channel.RealIds to query users
Definition: IrcProvider.cs:72
readonly IrcFeatures client
The IrcFeatures client
Definition: IrcProvider.cs:41
readonly ushort port
Port of the server to connect to
Definition: IrcProvider.cs:50
Configures the ASP.NET Core web application
Definition: IApplication.cs:8
readonly ILogger< IrcProvider > logger
The ILogger for the IrcProvider
Definition: IrcProvider.cs:36
readonly string address
Address of the server to connect to
Definition: IrcProvider.cs:46
override async Task Disconnect(CancellationToken cancellationToken)
Gracefully disconnects the provider. Implies a call to IDisposable.Dispose
Definition: IrcProvider.cs:329
readonly Dictionary< ulong, string > channelIdMap
Map of Channel.RealIds to channel names
Definition: IrcProvider.cs:67
Represents a message recieved by a IProvider
Definition: Message.cs:6
void HandleMessage(IrcEventArgs e, bool isPrivate)
Handle an IRC message
Definition: IrcProvider.cs:163
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the IrcProvider
Definition: IrcProvider.cs:31
Task listenTask
The Task used for IrcConnection.Listen(bool)
Definition: IrcProvider.cs:82
Represents a Providers.IProvider channel
Definition: Channel.cs:10
readonly IrcPasswordType passwordType
The IrcPasswordType of password
Definition: IrcProvider.cs:62
Represents a tgs_chat_user datum
Definition: User.cs:10
IrcPasswordType
Represents the type of a password for a ChatProvider.Irc
string VersionString
A more verbose version of Version
Definition: IApplication.cs:18
bool IsAdminChannel
If this is considered a channel for admin commands
Definition: Channel.cs:41
readonly string password
Password which will used for authentication
Definition: IrcProvider.cs:58
IrcProvider(IApplication application, IAsyncDelayer asyncDelayer, ILogger< IrcProvider > logger, string address, ushort port, string nickname, string password, IrcPasswordType?passwordType, bool useSsl)
Construct an IrcProvider
Definition: IrcProvider.cs:101