tgstation-server  4.4.0
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;
14 
15 namespace Tgstation.Server.Host.Components.Chat.Providers
16 {
20  sealed class IrcProvider : Provider
21  {
22  const int TimeoutSeconds = 5;
23 
25  public override bool Connected => client.IsConnected;
26 
28  public override string BotMention => client.Nickname;
29 
34 
38  readonly IrcFeatures client;
39 
43  readonly string address;
44 
48  readonly ushort port;
49 
53  readonly string nickname;
54 
58  readonly string password;
59 
64 
68  readonly Dictionary<ulong, string> channelIdMap;
69 
73  readonly Dictionary<ulong, string> queryChannelIdMap;
74 
79 
83  Task listenTask;
84 
89 
103  public IrcProvider(
104  IAssemblyInformationProvider assemblyInformationProvider,
105  IAsyncDelayer asyncDelayer,
106  ILogger<IrcProvider> logger,
107  string address,
108  ushort port,
109  string nickname,
110  string password,
111  IrcPasswordType? passwordType,
112  uint reconnectInterval,
113  bool useSsl)
114  : base(logger, reconnectInterval)
115  {
116  if (assemblyInformationProvider == null)
117  throw new ArgumentNullException(nameof(assemblyInformationProvider));
118  this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
119 
120  this.address = address ?? throw new ArgumentNullException(nameof(address));
121  this.port = port;
122  this.nickname = nickname ?? throw new ArgumentNullException(nameof(nickname));
123 
124  if (passwordType.HasValue && password == null)
125  throw new ArgumentNullException(nameof(password));
126 
127  if (password != null && !passwordType.HasValue)
128  throw new ArgumentNullException(nameof(passwordType));
129 
130  this.password = password;
131  this.passwordType = passwordType;
132 
133  client = new IrcFeatures
134  {
135  SupportNonRfc = true,
136  CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...",
137  AutoRejoin = true,
138  AutoRejoinOnKick = true,
139  AutoRelogin = true,
140  AutoRetry = true,
141  AutoRetryLimit = TimeoutSeconds,
142  AutoRetryDelay = TimeoutSeconds,
143  ActiveChannelSyncing = true,
144  AutoNickHandling = true,
145  CtcpVersion = assemblyInformationProvider.VersionString,
146  UseSsl = useSsl
147  };
148  if (useSsl)
149  client.ValidateServerCertificate = true; // dunno if it defaults to that or what
150 
151  client.OnChannelMessage += Client_OnChannelMessage;
152  client.OnQueryMessage += Client_OnQueryMessage;
153 
154  channelIdMap = new Dictionary<ulong, string>();
155  queryChannelIdMap = new Dictionary<ulong, string>();
156  channelIdCounter = 1;
157  disconnecting = false;
158  }
159 
161  public override void Dispose()
162  {
163  if (Connected)
164  {
165  disconnecting = true;
166  client.Disconnect(); // just closes the socket
167  }
168 
169  base.Dispose();
170  }
171 
177  void HandleMessage(IrcEventArgs e, bool isPrivate)
178  {
179  if (e.Data.Nick.ToUpperInvariant() == client.Nickname.ToUpperInvariant())
180  return;
181 
182  var username = e.Data.Nick;
183  var channelName = isPrivate ? username : e.Data.Channel;
184 
185  ulong MapAndGetChannelId(Dictionary<ulong, string> dicToCheck)
186  {
187  ulong? resultId = null;
188  if (!dicToCheck.Any(x =>
189  {
190  if (x.Value != channelName)
191  return false;
192  resultId = x.Key;
193  return true;
194  }))
195  {
196  resultId = channelIdCounter++;
197  dicToCheck.Add(resultId.Value, channelName);
198  if (dicToCheck == queryChannelIdMap)
199  channelIdMap.Add(resultId.Value, null);
200  }
201 
202  return resultId.Value;
203  }
204 
205  ulong userId, channelId;
206  lock (client)
207  {
208  userId = MapAndGetChannelId(queryChannelIdMap);
209  channelId = isPrivate ? userId : MapAndGetChannelId(channelIdMap);
210  }
211 
212  var message = new Message
213  {
214  Content = e.Data.Message,
215  User = new ChatUser
216  {
217  Channel = new ChannelRepresentation
218  {
219  ConnectionName = address,
220  FriendlyName = isPrivate ? String.Format(CultureInfo.InvariantCulture, "PM: {0}", channelName) : channelName,
221  RealId = channelId,
222  IsPrivateChannel = isPrivate
223 
224  // isAdmin and Tag populated by manager
225  },
226  FriendlyName = username,
227  RealId = userId,
228  Mention = username
229  }
230  };
231 
232  EnqueueMessage(message);
233  }
234 
240  void Client_OnQueryMessage(object sender, IrcEventArgs e) => HandleMessage(e, true);
241 
247  void Client_OnChannelMessage(object sender, IrcEventArgs e) => HandleMessage(e, false);
248 
250  public override Task<bool> Connect(CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
251  {
252  disconnecting = false;
253  lock (client)
254  try
255  {
256  client.Connect(address, port);
257 
258  cancellationToken.ThrowIfCancellationRequested();
259 
260  if (passwordType == IrcPasswordType.Server)
261  client.Login(nickname, nickname, 0, nickname, password);
262  else
263  {
264  if (passwordType == IrcPasswordType.Sasl)
265  {
266  client.WriteLine("CAP REQ :sasl", Priority.Critical); // needs to be put in the buffer before anything else
267  cancellationToken.ThrowIfCancellationRequested();
268  }
269 
270  client.Login(nickname, nickname, 0, nickname);
271  }
272 
273  if (passwordType == IrcPasswordType.NickServ)
274  {
275  cancellationToken.ThrowIfCancellationRequested();
276  client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password));
277  }
278  else if (passwordType == IrcPasswordType.Sasl)
279  {
280  // wait for the sasl ack or timeout
281  var recievedAck = false;
282  var recievedPlus = false;
283  client.OnReadLine += (sender, e) =>
284  {
285  if (e.Line.Contains("ACK :sasl", StringComparison.Ordinal))
286  recievedAck = true;
287  else if (e.Line.Contains("AUTHENTICATE +", StringComparison.Ordinal))
288  recievedPlus = true;
289  };
290 
291  var startTime = DateTimeOffset.Now;
292  var endTime = DateTimeOffset.Now.AddSeconds(TimeoutSeconds);
293  cancellationToken.ThrowIfCancellationRequested();
294 
295  var listenTimeSpan = TimeSpan.FromMilliseconds(10);
296  for (; !recievedAck && DateTimeOffset.Now <= endTime; asyncDelayer.Delay(listenTimeSpan, cancellationToken).GetAwaiter().GetResult())
297  client.Listen(false);
298 
299  client.WriteLine("AUTHENTICATE PLAIN", Priority.Critical);
300  cancellationToken.ThrowIfCancellationRequested();
301 
302  for (; !recievedPlus && DateTimeOffset.Now <= endTime; asyncDelayer.Delay(listenTimeSpan, cancellationToken).GetAwaiter().GetResult())
303  client.Listen(false);
304 
305  // Stolen! https://github.com/znc/znc/blob/1e697580155d5a38f8b5a377f3b1d94aaa979539/modules/sasl.cpp#L196
306  var authString = String.Format(CultureInfo.InvariantCulture, "{0}{1}{0}{1}{2}", nickname, '\0', password);
307  var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString));
308  var authLine = String.Format(CultureInfo.InvariantCulture, "AUTHENTICATE {0}", b64);
309  var chars = authLine.ToCharArray();
310  client.WriteLine(authLine, Priority.Critical);
311 
312  cancellationToken.ThrowIfCancellationRequested();
313  client.WriteLine("CAP END", Priority.Critical);
314  }
315 
316  client.Listen(false);
317 
318  listenTask = Task.Factory.StartNew(() =>
319  {
320  while (!disconnecting && client.IsConnected && client.Nickname != nickname)
321  {
322  client.ListenOnce(true);
323  if (disconnecting || !client.IsConnected)
324  break;
325  client.Listen(false);
326 
327  // ensure we have the correct nick
328  if (client.GetIrcUser(nickname) == null)
329  client.RfcNick(nickname);
330  }
331 
332  client.Listen();
333  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
334  }
335  catch (OperationCanceledException)
336  {
337  throw;
338  }
339  catch (Exception e)
340  {
341  Logger.LogWarning("Unable to connect to IRC: {0}", e);
342  return false;
343  }
344 
345  return true;
346  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
347 
349  protected override async Task DisconnectImpl(CancellationToken cancellationToken)
350  {
351  if (!Connected)
352  return;
353  try
354  {
355  await Task.Factory.StartNew(() =>
356  {
357  try
358  {
359  client.RfcQuit("Mr. Stark, I don't feel so good...", Priority.Critical); // priocritical otherwise it wont go through
360  }
361  catch (Exception e)
362  {
363  Logger.LogWarning("Error quitting IRC: {0}", e);
364  }
365  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
366  Dispose();
367  await listenTask.ConfigureAwait(false);
368  }
369  catch (OperationCanceledException)
370  {
371  throw;
372  }
373  catch (Exception e)
374  {
375  Logger.LogWarning("Error disconnecting from IRC! Exception: {0}", e);
376  }
377  }
378 
380  public override Task<IReadOnlyCollection<ChannelRepresentation>> MapChannels(
381  IEnumerable<ChatChannel> channels,
382  CancellationToken cancellationToken)
383  => Task.Factory.StartNew(() =>
384  {
385  if (channels.Any(x => x.IrcChannel == null))
386  throw new InvalidOperationException("ChatChannel missing IrcChannel!");
387  lock (client)
388  {
389  var channelsWithKeys = new Dictionary<string, string>();
390  var hs = new HashSet<string>(); // for unique inserts
391  foreach (var channel in channels)
392  {
393  var name = channel.GetIrcChannelName();
394  var key = channel.GetIrcChannelKey();
395  if (hs.Add(name) && key != null)
396  channelsWithKeys.Add(name, key);
397  }
398 
399  var toPart = new List<string>();
400  foreach (var activeChannel in client.JoinedChannels)
401  if (!hs.Remove(activeChannel))
402  toPart.Add(activeChannel);
403 
404  foreach (var channelToLeave in toPart)
405  client.RfcPart(channelToLeave, "Pretty nice abscond!");
406  foreach (var channelToJoin in hs)
407  if (channelsWithKeys.TryGetValue(channelToJoin, out var key))
408  client.RfcJoin(channelToJoin, key);
409  else
410  client.RfcJoin(channelToJoin);
411 
412  return (IReadOnlyCollection<ChannelRepresentation>)channels
413  .Select(x =>
414  {
415  var channelName = x.GetIrcChannelName();
416  ulong? id = null;
417  if (!channelIdMap.Any(y =>
418  {
419  if (y.Value != channelName)
420  return false;
421  id = y.Key;
422  return true;
423  }))
424  {
425  id = channelIdCounter++;
426  channelIdMap.Add(id.Value, channelName);
427  }
428 
429  return new ChannelRepresentation
430  {
431  RealId = id.Value,
432  IsAdminChannel = x.IsAdminChannel == true,
433  ConnectionName = address,
434  FriendlyName = channelIdMap[id.Value],
435  IsPrivateChannel = false,
436  Tag = x.Tag
437  };
438  })
439  .ToList();
440  }
441  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
442 
444  public override Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
445  {
446  // IRC doesn't allow newlines
447  message = String.Concat(
448  message
449  .Where(x => x != '\r')
450  .Select(x => x == '\n' ? '|' : x));
451 
452  var channelName = channelIdMap[channelId];
453  SendType sendType;
454  if (channelName == null)
455  {
456  channelName = queryChannelIdMap[channelId];
457  sendType = SendType.Notice;
458  }
459  else
460  sendType = SendType.Message;
461  try
462  {
463  client.SendMessage(sendType, channelName, message);
464  }
465  catch (Exception e)
466  {
467  Logger.LogWarning("Unable to send to channel: {0}", e);
468  }
469  }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
470 
472  public override async Task<Func<string, string, Task>> SendUpdateMessage(
473  Models.RevisionInformation revisionInformation,
474  Version byondVersion,
475  DateTimeOffset? estimatedCompletionTime,
476  string gitHubOwner,
477  string gitHubRepo,
478  ulong channelId,
479  bool localCommitPushed,
480  CancellationToken cancellationToken)
481  {
482  var commitInsert = revisionInformation.CommitSha.Substring(0, 7);
483  string remoteCommitInsert;
484  if (revisionInformation.CommitSha == revisionInformation.OriginCommitSha)
485  {
486  commitInsert = String.Format(CultureInfo.InvariantCulture, localCommitPushed ? "^{0}" : "{0}", commitInsert);
487  remoteCommitInsert = String.Empty;
488  }
489  else
490  remoteCommitInsert = String.Format(CultureInfo.InvariantCulture, ". Remote commit: ^{0}", revisionInformation.OriginCommitSha.Substring(0, 7));
491 
492  var testmergeInsert = (revisionInformation.ActiveTestMerges?.Count ?? 0) == 0 ? String.Empty : String.Format(CultureInfo.InvariantCulture, " (Test Merges: {0})",
493  String.Join(", ", revisionInformation.ActiveTestMerges.Select(x => x.TestMerge).Select(x =>
494  {
495  var result = String.Format(CultureInfo.InvariantCulture, "#{0} at {1}", x.Number, x.PullRequestRevision.Substring(0, 7));
496  if (x.Comment != null)
497  result += String.Format(CultureInfo.InvariantCulture, " ({0})", x.Comment);
498  return result;
499  })));
500 
501  await SendMessage(
502  channelId,
503  String.Format(
504  CultureInfo.InvariantCulture,
505  "DM: Deploying revision: {0}{1}{2} BYOND Version: {3}{4}",
506  commitInsert,
507  testmergeInsert,
508  remoteCommitInsert,
509  byondVersion.Build > 0
510  ? byondVersion.ToString()
511  : $"{byondVersion.Major}.{byondVersion.Minor}",
512  estimatedCompletionTime.HasValue
513  ? $" ETA: {estimatedCompletionTime - DateTimeOffset.Now}"
514  : String.Empty),
515  cancellationToken).ConfigureAwait(false);
516 
517  return (errorMessage, dreamMakerOutput) => SendMessage(
518  channelId,
519  $"DM: Deployment {(errorMessage == null ? "complete" : "failed")}!",
520  cancellationToken);
521  }
522  }
523 }
ulong channelIdCounter
Id counter for channelIdMap
Definition: IrcProvider.cs:78
IrcProvider(IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, ILogger< IrcProvider > logger, string address, ushort port, string nickname, string password, IrcPasswordType?passwordType, uint reconnectInterval, bool useSsl)
Construct an IrcProvider
Definition: IrcProvider.cs:103
readonly Dictionary< ulong, string > queryChannelIdMap
Map of ChannelRepresentation.RealIds to query users
Definition: IrcProvider.cs:73
readonly IrcFeatures client
The IrcFeatures client
Definition: IrcProvider.cs:38
readonly ushort port
Port of the server to connect to
Definition: IrcProvider.cs:48
bool IsAdminChannel
If this is considered a channel for admin commands
readonly string address
Address of the server to connect to
Definition: IrcProvider.cs:43
readonly Dictionary< ulong, string > channelIdMap
Map of ChannelRepresentation.RealIds to channel names
Definition: IrcProvider.cs:68
Represents a message recieved by a IProvider
Definition: Message.cs:6
void HandleMessage(IrcEventArgs e, bool isPrivate)
Handle an IRC message
Definition: IrcProvider.cs:177
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the IrcProvider
Definition: IrcProvider.cs:33
override async Task DisconnectImpl(CancellationToken cancellationToken)
Gracefully disconnects the provider.
Definition: IrcProvider.cs:349
Task listenTask
The Task used for IrcConnection.Listen(bool)
Definition: IrcProvider.cs:83
Represents a tgs_chat_user datum
Definition: ChatUser.cs:10
override async Task< Func< string, string, Task > > SendUpdateMessage(Models.RevisionInformation revisionInformation, Version byondVersion, DateTimeOffset?estimatedCompletionTime, string gitHubOwner, string gitHubRepo, ulong channelId, bool localCommitPushed, CancellationToken cancellationToken)
Definition: IrcProvider.cs:472
readonly IrcPasswordType passwordType
The IrcPasswordType of password
Definition: IrcProvider.cs:63
IrcPasswordType
Represents the type of a password for a ChatProvider.Irc
readonly string password
Password which will used for authentication
Definition: IrcProvider.cs:58