tgstation-server 6.14.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
IrcProvider.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.Globalization;
4using System.Linq;
5using System.Text;
6using System.Threading;
7using System.Threading.Tasks;
8
9using Meebey.SmartIrc4net;
10using Microsoft.Extensions.Logging;
11
12using Newtonsoft.Json;
13
22
24{
28 sealed class IrcProvider : Provider
29 {
33 const int PreambleMessageLength = 12;
34
38 const int MessageBytesLimit = 512;
39
41 public override bool Connected => client.IsConnected;
42
44 public override string BotMention => client.Nickname;
45
49 readonly string address;
50
54 readonly ushort port;
55
59 readonly bool ssl;
60
64 readonly string nickname;
65
69 readonly string password;
70
75
79 readonly Dictionary<ulong, string?> channelIdMap;
80
84 readonly Dictionary<ulong, string> queryChannelIdMap;
85
90
95
99 IrcFeatures client;
100
105
110
122 IAsyncDelayer asyncDelayer,
123 ILogger<IrcProvider> logger,
124 IAssemblyInformationProvider assemblyInformationProvider,
125 Models.ChatBot chatBot,
127 : base(jobManager, asyncDelayer, logger, chatBot)
128 {
129 ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
130 ArgumentNullException.ThrowIfNull(loggingConfiguration);
131
132 var builder = chatBot.CreateConnectionStringBuilder();
133 if (builder == null || !builder.Valid || builder is not IrcConnectionStringBuilder ircBuilder)
134 throw new InvalidOperationException("Invalid ChatConnectionStringBuilder!");
135
136 address = ircBuilder.Address!;
137 port = ircBuilder.Port!.Value;
138 ssl = ircBuilder.UseSsl!.Value;
139 nickname = ircBuilder.Nickname!;
140
141 password = ircBuilder.Password!;
142 passwordType = ircBuilder.PasswordType;
143
144 assemblyInfo = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
145 this.loggingConfiguration = loggingConfiguration ?? throw new ArgumentNullException(nameof(loggingConfiguration));
146
148
149 channelIdMap = new Dictionary<ulong, string?>();
150 queryChannelIdMap = new Dictionary<ulong, string>();
152 }
153
155 public override async ValueTask DisposeAsync()
156 {
157 await base.DisposeAsync();
158
159 // DCT: None available
160 await HardDisconnect(CancellationToken.None);
161 }
162
164 public override async ValueTask SendMessage(Message? replyTo, MessageContent message, ulong channelId, CancellationToken cancellationToken)
165 {
166 ArgumentNullException.ThrowIfNull(message);
167
168 await Task.Factory.StartNew(
169 () =>
170 {
171 // IRC doesn't allow newlines
172 // Explicitly ignore embeds
173 var messageText = message.Text;
174 messageText ??= $"Embed Only: {JsonConvert.SerializeObject(message.Embed)}";
175
176 messageText = String.Concat(
177 messageText
178 .Where(x => x != '\r')
179 .Select(x => x == '\n' ? '|' : x));
180
181 var channelName = channelIdMap[channelId];
182 SendType sendType;
183 if (channelName == null)
184 {
185 channelName = queryChannelIdMap[channelId];
186 sendType = SendType.Notice;
187 }
188 else
189 sendType = SendType.Message;
190
191 var messageSize = Encoding.UTF8.GetByteCount(messageText) + Encoding.UTF8.GetByteCount(channelName) + PreambleMessageLength;
192 var messageTooLong = messageSize > MessageBytesLimit;
193 if (messageTooLong)
194 messageText = $"TGS: Could not send message to IRC. Line write exceeded protocol limit of {MessageBytesLimit}B.";
195
196 try
197 {
198 client.SendMessage(sendType, channelName, messageText);
199 }
200 catch (Exception e)
201 {
202 Logger.LogWarning(e, "Unable to send to channel {channelName}!", channelName);
203 return;
204 }
205
206 if (messageTooLong)
207 Logger.LogWarning(
208 "Failed to send to channel {channelId}: Message size ({messageSize}B) exceeds IRC limit of 512B",
209 channelId,
210 messageSize);
211 },
212 cancellationToken,
214 TaskScheduler.Current);
215 }
216
218 public override async ValueTask<Func<string?, string, ValueTask<Func<bool, ValueTask>>>> SendUpdateMessage(
219 Models.RevisionInformation revisionInformation,
220 EngineVersion engineVersion,
221 DateTimeOffset? estimatedCompletionTime,
222 string? gitHubOwner,
223 string? gitHubRepo,
224 ulong channelId,
225 bool localCommitPushed,
226 CancellationToken cancellationToken)
227 {
228 ArgumentNullException.ThrowIfNull(revisionInformation);
229 ArgumentNullException.ThrowIfNull(engineVersion);
230
231 var commitInsert = revisionInformation.CommitSha![..7];
232 string remoteCommitInsert;
233 if (revisionInformation.CommitSha == revisionInformation.OriginCommitSha)
234 {
235 commitInsert = String.Format(CultureInfo.InvariantCulture, localCommitPushed ? "^{0}" : "{0}", commitInsert);
236 remoteCommitInsert = String.Empty;
237 }
238 else
239 remoteCommitInsert = String.Format(CultureInfo.InvariantCulture, ". Remote commit: ^{0}", revisionInformation.OriginCommitSha![..7]);
240
241 var testmergeInsert = (revisionInformation.ActiveTestMerges?.Count ?? 0) == 0
242 ? String.Empty
243 : String.Format(
244 CultureInfo.InvariantCulture,
245 " (Test Merges: {0})",
246 String.Join(
247 ", ",
248 revisionInformation
249 .ActiveTestMerges!
250 .Select(x => x.TestMerge)
251 .Select(x =>
252 {
253 var result = String.Format(CultureInfo.InvariantCulture, "#{0} at {1}", x.Number, x.TargetCommitSha![..7]);
254 if (x.Comment != null)
255 result += String.Format(CultureInfo.InvariantCulture, " ({0})", x.Comment);
256 return result;
257 })));
258
259 var prefix = GetEngineCompilerPrefix(engineVersion.Engine!.Value);
260 await SendMessage(
261 null,
263 {
264 Text = String.Format(
265 CultureInfo.InvariantCulture,
266 $"{prefix}: Deploying revision: {0}{1}{2} BYOND Version: {3}{4}",
267 commitInsert,
268 testmergeInsert,
269 remoteCommitInsert,
270 engineVersion.ToString(),
271 estimatedCompletionTime.HasValue
272 ? $" ETA: {estimatedCompletionTime - DateTimeOffset.UtcNow}"
273 : String.Empty),
274 },
275 channelId,
276 cancellationToken);
277
278 return async (errorMessage, dreamMakerOutput) =>
279 {
280 await SendMessage(
281 null,
283 {
284 Text = $"{prefix}: Deployment {(errorMessage == null ? "complete" : "failed")}!",
285 },
286 channelId,
287 cancellationToken);
288
289 return active => ValueTask.CompletedTask;
290 };
291 }
292
294 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> MapChannelsImpl(
295 IEnumerable<Models.ChatChannel> channels,
296 CancellationToken cancellationToken)
297 => await Task.Factory.StartNew(
298 () =>
299 {
300 if (channels.Any(x => x.IrcChannel == null))
301 throw new InvalidOperationException("ChatChannel missing IrcChannel!");
302 lock (client)
303 {
304 var channelsWithKeys = new Dictionary<string, string>();
305 var hs = new HashSet<string>(); // for unique inserts
306 foreach (var channel in channels)
307 {
308 var name = channel.GetIrcChannelName();
309 var key = channel.GetIrcChannelKey();
310 if (hs.Add(name) && key != null)
311 channelsWithKeys.Add(name, key);
312 }
313
314 var toPart = new List<string>();
315 foreach (var activeChannel in client.JoinedChannels)
316 if (!hs.Remove(activeChannel))
317 toPart.Add(activeChannel);
318
319 foreach (var channelToLeave in toPart)
320 client.RfcPart(channelToLeave, "Pretty nice abscond!");
321 foreach (var channelToJoin in hs)
322 if (channelsWithKeys.TryGetValue(channelToJoin, out var key))
323 client.RfcJoin(channelToJoin, key);
324 else
325 client.RfcJoin(channelToJoin);
326
327 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
328 channels
329 .Select(dbChannel =>
330 {
331 var channelName = dbChannel.GetIrcChannelName();
332 ulong? id = null;
333 if (!channelIdMap.Any(y =>
334 {
335 if (y.Value != channelName)
336 return false;
337 id = y.Key;
338 return true;
339 }))
340 {
341 id = channelIdCounter++;
342 channelIdMap.Add(id.Value, channelName);
343 }
344
345 return new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
346 dbChannel,
347 new List<ChannelRepresentation>
348 {
349 new(address, channelName, id!.Value)
350 {
351 Tag = dbChannel.Tag,
352 IsAdminChannel = dbChannel.IsAdminChannel == true,
353 IsPrivateChannel = false,
354 EmbedsSupported = false,
355 },
356 });
357 }));
358 }
359 },
360 cancellationToken,
362 TaskScheduler.Current);
363
365 protected override async ValueTask Connect(CancellationToken cancellationToken)
366 {
367 cancellationToken.ThrowIfCancellationRequested();
368 try
369 {
370 await Task.Factory.StartNew(
371 () =>
372 {
373 client = InstantiateClient();
374 client.Connect(address, port);
375 },
376 cancellationToken,
378 TaskScheduler.Current)
379 .WaitAsync(cancellationToken);
380
381 cancellationToken.ThrowIfCancellationRequested();
382
383 listenTask = Task.Factory.StartNew(
384 () =>
385 {
386 Logger.LogTrace("Starting blocking listen...");
387 try
388 {
389 client.Listen();
390 }
391 catch (Exception ex)
392 {
393 Logger.LogWarning(ex, "IRC Main Listen Exception!");
394 }
395
396 Logger.LogTrace("Exiting listening task...");
397 },
398 cancellationToken,
400 TaskScheduler.Current);
401
402 Logger.LogTrace("Authenticating ({passwordType})...", passwordType);
403 switch (passwordType)
404 {
405 case IrcPasswordType.Server:
406 client.RfcPass(password);
407 await Login(client, nickname, cancellationToken);
408 break;
409 case IrcPasswordType.NickServ:
410 await Login(client, nickname, cancellationToken);
411 cancellationToken.ThrowIfCancellationRequested();
412 client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password));
413 break;
414 case IrcPasswordType.Sasl:
415 await SaslAuthenticate(cancellationToken);
416 break;
417 case IrcPasswordType.Oper:
418 await Login(client, nickname, cancellationToken);
419 cancellationToken.ThrowIfCancellationRequested();
420 client.RfcOper(nickname, password, Priority.Critical);
421 break;
422 case null:
423 await Login(client, nickname, cancellationToken);
424 break;
425 default:
426 throw new InvalidOperationException($"Invalid IrcPasswordType: {passwordType.Value}");
427 }
428
429 cancellationToken.ThrowIfCancellationRequested();
430
431 Logger.LogTrace("Connection established!");
432 }
433 catch (Exception e) when (e is not OperationCanceledException)
434 {
435 throw new JobException(ErrorCode.ChatCannotConnectProvider, e);
436 }
437 }
438
440 protected override async ValueTask DisconnectImpl(CancellationToken cancellationToken)
441 {
442 try
443 {
444 await Task.Factory.StartNew(
445 () =>
446 {
447 try
448 {
449 client.RfcQuit("Mr. Stark, I don't feel so good...", Priority.Critical); // priocritical otherwise it wont go through
450 }
451 catch (Exception e)
452 {
453 Logger.LogWarning(e, "Error quitting IRC!");
454 }
455 },
456 cancellationToken,
458 TaskScheduler.Current);
459 await HardDisconnect(cancellationToken);
460 }
461 catch (OperationCanceledException)
462 {
463 throw;
464 }
465 catch (Exception e)
466 {
467 Logger.LogWarning(e, "Error disconnecting from IRC!");
468 }
469 }
470
479 async ValueTask Login(IrcFeatures client, string nickname, CancellationToken cancellationToken)
480 {
481 var promise = new TaskCompletionSource<object>();
482
483 void Callback(object? sender, EventArgs e)
484 {
485 Logger.LogTrace("IRC Registered.");
486 promise.TrySetResult(e);
487 }
488
489 client.OnRegistered += Callback;
490
491 client.Login(nickname, nickname, 0, nickname);
492
493 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
494 cts.CancelAfter(TimeSpan.FromSeconds(30));
495
496 try
497 {
498 await promise.Task.WaitAsync(cts.Token);
499 client.OnRegistered -= Callback;
500 }
501 catch (OperationCanceledException)
502 {
503 if (client.IsConnected)
504 client.Disconnect();
505 throw new JobException("Timed out waiting for IRC Registration");
506 }
507 }
508
514 void HandleMessage(IrcEventArgs e, bool isPrivate)
515 {
516 if (e.Data.Nick.Equals(client.Nickname, StringComparison.OrdinalIgnoreCase))
517 return;
518
519 var username = e.Data.Nick;
520 var channelName = isPrivate ? username : e.Data.Channel;
521
522 ulong MapAndGetChannelId(Dictionary<ulong, string?> dicToCheck)
523 {
524 ulong? resultId = null;
525 if (!dicToCheck.Any(x =>
526 {
527 if (x.Value != channelName)
528 return false;
529 resultId = x.Key;
530 return true;
531 }))
532 {
533 resultId = channelIdCounter++;
534 dicToCheck.Add(resultId.Value, channelName);
535 if (dicToCheck == queryChannelIdMap)
536 channelIdMap.Add(resultId.Value, null);
537 }
538
539 return resultId!.Value;
540 }
541
542 ulong userId, channelId;
543 lock (client)
544 {
545 userId = MapAndGetChannelId(new Dictionary<ulong, string?>(queryChannelIdMap
546 .Cast<KeyValuePair<ulong, string?>>())); // NRT my beloathed
547 channelId = isPrivate ? userId : MapAndGetChannelId(channelIdMap);
548 }
549
550 var channelFriendlyName = isPrivate ? String.Format(CultureInfo.InvariantCulture, "PM: {0}", channelName) : channelName;
551 var message = new Message(
552 new ChatUser(
553 new ChannelRepresentation(address, channelFriendlyName, channelId)
554 {
555 IsPrivateChannel = isPrivate,
556 EmbedsSupported = false,
557
558 // isAdmin and Tag populated by manager
559 },
560 username,
561 username,
562 userId),
563 e.Data.Message);
564
565 EnqueueMessage(message);
566 }
567
573 void Client_OnQueryMessage(object sender, IrcEventArgs e) => HandleMessage(e, true);
574
580 void Client_OnChannelMessage(object sender, IrcEventArgs e) => HandleMessage(e, false);
581
587 Task NonBlockingListen(CancellationToken cancellationToken) => Task.Factory.StartNew(
588 () =>
589 {
590 try
591 {
592 client.Listen(false);
593 }
594 catch (Exception ex)
595 {
596 Logger.LogWarning(ex, "IRC Non-Blocking Listen Exception!");
597 }
598 },
599 cancellationToken,
600 TaskCreationOptions.None,
601 TaskScheduler.Current)
602 .WaitAsync(cancellationToken);
603
609 async ValueTask SaslAuthenticate(CancellationToken cancellationToken)
610 {
611 client.WriteLine("CAP REQ :sasl", Priority.Critical); // needs to be put in the buffer before anything else
612 cancellationToken.ThrowIfCancellationRequested();
613
614 Logger.LogTrace("Logging in...");
615 client.Login(nickname, nickname, 0, nickname);
616 cancellationToken.ThrowIfCancellationRequested();
617
618 // wait for the SASL ack or timeout
619 var receivedAck = false;
620 var receivedPlus = false;
621
622 void AuthenticationDelegate(object sender, ReadLineEventArgs e)
623 {
624 if (e.Line.Contains("ACK :sasl", StringComparison.Ordinal))
625 receivedAck = true;
626 else if (e.Line.Contains("AUTHENTICATE +", StringComparison.Ordinal))
627 receivedPlus = true;
628 }
629
630 Logger.LogTrace("Performing handshake...");
631 client.OnReadLine += AuthenticationDelegate;
632 try
633 {
634 using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
635 timeoutCts.CancelAfter(TimeSpan.FromSeconds(25));
636 var timeoutToken = timeoutCts.Token;
637
638 var listenTimeSpan = TimeSpan.FromMilliseconds(10);
639 for (; !receivedAck;
640 await AsyncDelayer.Delay(listenTimeSpan, timeoutToken))
641 await NonBlockingListen(cancellationToken);
642
643 client.WriteLine("AUTHENTICATE PLAIN", Priority.Critical);
644 timeoutToken.ThrowIfCancellationRequested();
645
646 for (; !receivedPlus;
647 await AsyncDelayer.Delay(listenTimeSpan, timeoutToken))
648 await NonBlockingListen(cancellationToken);
649 }
650 finally
651 {
652 client.OnReadLine -= AuthenticationDelegate;
653 }
654
655 cancellationToken.ThrowIfCancellationRequested();
656
657 // Stolen! https://github.com/znc/znc/blob/1e697580155d5a38f8b5a377f3b1d94aaa979539/modules/sasl.cpp#L196
658 Logger.LogTrace("Sending credentials...");
659 var authString = String.Format(
660 CultureInfo.InvariantCulture,
661 "{0}{1}{0}{1}{2}",
662 nickname,
663 '\0',
664 password);
665 var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString));
666 var authLine = $"AUTHENTICATE {b64}";
667 client.WriteLine(authLine, Priority.Critical);
668 cancellationToken.ThrowIfCancellationRequested();
669
670 Logger.LogTrace("Finishing authentication...");
671 client.WriteLine("CAP END", Priority.Critical);
672 }
673
679 async ValueTask HardDisconnect(CancellationToken cancellationToken)
680 {
681 if (!Connected)
682 {
683 Logger.LogTrace("Not hard disconnecting, already offline");
684 return;
685 }
686
687 Logger.LogTrace("Hard disconnect");
688
689 // This call blocks permanently randomly sometimes
690 // Frankly I don't give a shit
691 var disconnectTask = Task.Factory.StartNew(
692 () =>
693 {
694 try
695 {
696 client.Disconnect();
697 }
698 catch (Exception e)
699 {
700 Logger.LogWarning(e, "Error disconnecting IRC!");
701 }
702 },
703 cancellationToken,
705 TaskScheduler.Current);
706
707 await Task.WhenAny(
708 Task.WhenAll(
709 disconnectTask,
710 listenTask ?? Task.CompletedTask),
711 AsyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken).AsTask());
712 }
713
719 IrcFeatures InstantiateClient()
720 {
721 var newClient = new IrcFeatures
722 {
723 SupportNonRfc = true,
724 CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...",
725 AutoRejoin = true,
726 AutoRejoinOnKick = true,
727 AutoRelogin = false,
728 AutoRetry = false,
729 AutoReconnect = false,
730 ActiveChannelSyncing = true,
731 AutoNickHandling = true,
732 CtcpVersion = assemblyInfo.VersionString,
733 UseSsl = ssl,
734 EnableUTF8Recode = true,
735 };
736 if (ssl)
737 newClient.ValidateServerCertificate = true; // dunno if it defaults to that or what
738
739 newClient.OnChannelMessage += Client_OnChannelMessage;
740 newClient.OnQueryMessage += Client_OnQueryMessage;
741
742 if (loggingConfiguration.ProviderNetworkDebug)
743 {
744 newClient.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line);
745 newClient.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line);
746 }
747
748 newClient.OnError += (sender, e) =>
749 {
750 Logger.LogError("IRC ERROR: {error}", e.ErrorMessage);
751 newClient.Disconnect();
752 };
753
754 return newClient;
755 }
756 }
757}
Information about an engine installation.
ChatConnectionStringBuilder for ChatProvider.Irc.
Represents a tgs_chat_user datum.
Definition ChatUser.cs:12
IrcFeatures InstantiateClient()
Creates a new instance of the IRC client. Reusing the same client after a disconnection seems to caus...
readonly IAssemblyInformationProvider assemblyInfo
The IAssemblyInformationProvider obtained from constructor, used for the CTCP version string.
readonly ushort port
Port of the server to connect to.
readonly string password
Password which will used for authentication.
override async ValueTask< Dictionary< Models.ChatChannel, IEnumerable< ChannelRepresentation > > > MapChannelsImpl(IEnumerable< Models.ChatChannel > channels, CancellationToken cancellationToken)
override async ValueTask< Func< string?, string, ValueTask< Func< bool, ValueTask > > > > SendUpdateMessage(Models.RevisionInformation revisionInformation, EngineVersion engineVersion, DateTimeOffset? estimatedCompletionTime, string? gitHubOwner, string? gitHubRepo, ulong channelId, bool localCommitPushed, CancellationToken cancellationToken)
Send the message for a deployment.A ValueTask<TResult> resulting in a Func<T1, T2,...
override string BotMention
The string that indicates the IProvider was mentioned.
override async ValueTask DisconnectImpl(CancellationToken cancellationToken)
IrcProvider(IJobManager jobManager, IAsyncDelayer asyncDelayer, ILogger< IrcProvider > logger, IAssemblyInformationProvider assemblyInformationProvider, Models.ChatBot chatBot, FileLoggingConfiguration loggingConfiguration)
Initializes a new instance of the IrcProvider class.
async ValueTask HardDisconnect(CancellationToken cancellationToken)
Attempt to disconnect from IRC immediately.
const int PreambleMessageLength
Length of the preamble when writing a message to the server. Must be summed with the channel name to ...
async ValueTask SaslAuthenticate(CancellationToken cancellationToken)
Run SASL authentication on client.
readonly string address
Address of the server to connect to.
void Client_OnQueryMessage(object sender, IrcEventArgs e)
When a query message is received in IRC.
override async ValueTask Connect(CancellationToken cancellationToken)
readonly? IrcPasswordType passwordType
The IrcPasswordType of password.
async ValueTask Login(IrcFeatures client, string nickname, CancellationToken cancellationToken)
Register the client on the network.
void HandleMessage(IrcEventArgs e, bool isPrivate)
Handle an IRC message.
readonly Dictionary< ulong, string > queryChannelIdMap
Map of ChannelRepresentation.RealIds to query users.
override bool Connected
If the IProvider is currently connected.
readonly bool ssl
Wether or not this IRC client is to use ssl.
override async ValueTask SendMessage(Message? replyTo, MessageContent message, ulong channelId, CancellationToken cancellationToken)
Send a message to the IProvider.A ValueTask representing the running operation.
readonly FileLoggingConfiguration loggingConfiguration
The FileLoggingConfiguration for the IrcProvider.
void Client_OnChannelMessage(object sender, IrcEventArgs e)
When a channel message is received in IRC.
const int MessageBytesLimit
Hard limit to sendable message size in bytes.
Task? listenTask
The ValueTask used for IrcConnection.Listen(bool).
Task NonBlockingListen(CancellationToken cancellationToken)
Perform a non-blocking IrcConnection.Listen(bool).
readonly Dictionary< ulong, string?> channelIdMap
Map of ChannelRepresentation.RealIds to channel names.
Represents a message received by a IProvider.
Definition Message.cs:9
static string GetEngineCompilerPrefix(Api.Models.EngineType engineType)
Get the prefix for messages about deployments.
readonly IJobManager jobManager
The IJobManager for the Provider.
Definition Provider.cs:40
ILogger< Provider > Logger
The ILogger for the Provider.
Definition Provider.cs:35
Represents a message to send to a chat provider.
IIOManager that resolves paths to Environment.CurrentDirectory.
const TaskCreationOptions BlockingTaskCreationOptions
The TaskCreationOptions used to spawn Tasks for potentially long running, blocking operations.
Operation exceptions thrown from the context of a Models.Job.
async ValueTask Delay(TimeSpan timeSpan, CancellationToken cancellationToken)
Create a Task that completes after a given timeSpan .A ValueTask representing the running operation.
Manages the runtime of Jobs.
IrcPasswordType
Represents the type of a password for a ChatProvider.Irc.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition ErrorCode.cs:12