-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add IRC connection to chat (#3)
* feat: add initial IRC implementation * fix: re-add accidentally deleted file * fix: finish message queue on disconnect * feat: specify what kind of messages should be received on which connection * fix: read connection doesn't need to pass NOTICEs There are only 2 cases where a NOTICE can be received on the read connection. Both of them are received on an unsuccessful JOIN and can be handled there. * style: only apple swiftlint rule disabling for the next occurence * chore: remove TODO * build: update TwitchIRC dependency * fix: add clientNonce into the PRIVMSG directly, see MahdiBM/TwitchIRC#3 * chore: revert unrelated change * build: update TwitchIRC dependency * chore: add TODO comment * build: add websocket-kit as a dependency for linux * refactor: rewrite the continuation system * chore: handle potential unknown types of websocket messages * fix: clean up channel names before sending them over IRC * fix: remove completed continuations from the connections list * various changes, too lazy for separate commits * feat: implement timeouts for IRC requests * build: update runner to macOS 13 * build: update platform requirements to macOS 13, iOS/iPadOS 16, watchOS 9
- Loading branch information
1 parent
05c4e56
commit 3a2ba37
Showing
19 changed files
with
677 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import TwitchIRC | ||
|
||
public class ChatClient { | ||
private let authentication: IRCAuthentication | ||
|
||
private let client: TwitchIRCClient | ||
private let options: ChatClientOptions | ||
|
||
public init( | ||
_ authentication: IRCAuthentication, | ||
with options: ChatClientOptions = ChatClientOptions() | ||
) { | ||
self.authentication = authentication | ||
self.options = options | ||
|
||
self.client = TwitchIRCClient(with: self.authentication, options: options) | ||
} | ||
|
||
// TODO: specify error type | ||
public func connect() async throws -> AsyncThrowingStream< | ||
IncomingMessage, Error | ||
> { return try await client.connect() } | ||
|
||
public func disconnect() { client.disconnect() } | ||
|
||
public func send( | ||
_ message: String, in channel: String, replyingTo messageId: String? = nil, | ||
nonce: String? = nil | ||
) async throws { | ||
try await client.send( | ||
message, in: cleanChannelName(channel), replyingTo: messageId, | ||
nonce: nonce) | ||
} | ||
|
||
public func join(to channel: String) async throws { | ||
try await client.join(to: cleanChannelName(channel)) | ||
} | ||
|
||
// TODO: check for proper parallelization of JOINs | ||
public func joinAll(channels: String...) async throws { | ||
for channel in channels { try await join(to: cleanChannelName(channel)) } | ||
} | ||
|
||
public func part(from channel: String) async throws { | ||
try await client.part(from: cleanChannelName(channel)) | ||
} | ||
|
||
private func cleanChannelName(_ channel: String) -> String { | ||
return channel.lowercased().trimmingCharacters(in: ["#", " "]) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import Foundation | ||
|
||
public struct ChatClientOptions { | ||
public var joinTimeout: Duration = .seconds(5) | ||
public var partTimeout: Duration = .seconds(5) | ||
public var connectTimeout: Duration = .seconds(10) | ||
|
||
public init() {} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
public enum ChatEvent { | ||
case message(() -> Void) | ||
case userstate(() -> Void) | ||
case timeoutOrBan(() -> Void) | ||
case deletedMessage(() -> Void) | ||
case announcement(() -> Void) | ||
case sub(() -> Void) | ||
case raid(() -> Void) | ||
case whisper(() -> Void) | ||
|
||
case ROOMSTATE(() -> Void) | ||
case NOTICE(() -> Void) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
public enum IRCError: Error { | ||
case loginFailed | ||
case channelSuspended(_ channel: String) | ||
case bannedFromChannel(_ channel: String) | ||
case timedOut | ||
} |
38 changes: 38 additions & 0 deletions
38
Sources/Twitch/Chat/IRC/TwitchContinuations/AuthenticationContinuation.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import TwitchIRC | ||
|
||
internal actor AuthenticationContinuation: TwitchContinuation { | ||
private var continuation: CheckedContinuation<Void, Error>? | ||
|
||
internal func check(message: IncomingMessage) async -> Bool { | ||
return checkConnectionNotice(message: message) | ||
|| checkNotice(message: message) | ||
} | ||
|
||
private func checkConnectionNotice(message: IncomingMessage) -> Bool { | ||
guard case .connectionNotice = message else { return false } | ||
|
||
continuation?.resume() | ||
continuation = nil | ||
return true | ||
} | ||
|
||
private func checkNotice(message: IncomingMessage) -> Bool { | ||
guard case .notice(let notice) = message else { return false } | ||
guard case .global(let message) = notice.kind else { return false } | ||
|
||
guard message.contains("Login authentication failed") else { return false } | ||
|
||
continuation?.resume(throwing: IRCError.loginFailed) | ||
continuation = nil | ||
return true | ||
} | ||
|
||
internal func cancel(throwing error: IRCError) { | ||
continuation?.resume(throwing: error) | ||
continuation = nil | ||
} | ||
|
||
internal func setContinuation( | ||
_ continuation: CheckedContinuation<Void, Error> | ||
) { self.continuation = continuation } | ||
} |
23 changes: 23 additions & 0 deletions
23
Sources/Twitch/Chat/IRC/TwitchContinuations/CapabilitiesContinuation.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import TwitchIRC | ||
|
||
internal actor CapabilitiesContinuation: TwitchContinuation { | ||
private var continuation: CheckedContinuation<Void, Error>? | ||
|
||
internal func check(message: IncomingMessage) async -> Bool { | ||
guard case .capabilities = message else { return false } | ||
|
||
continuation?.resume() | ||
continuation = nil | ||
|
||
return true | ||
} | ||
|
||
internal func cancel(throwing error: IRCError) { | ||
continuation?.resume(throwing: error) | ||
continuation = nil | ||
} | ||
|
||
internal func setContinuation( | ||
_ continuation: CheckedContinuation<Void, Error> | ||
) { self.continuation = continuation } | ||
} |
41 changes: 41 additions & 0 deletions
41
Sources/Twitch/Chat/IRC/TwitchContinuations/ContinuationQueue.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import Foundation | ||
import TwitchIRC | ||
|
||
internal struct ContinuationQueue { | ||
private var continuations: [any TwitchContinuation] = [] | ||
|
||
mutating func register( | ||
_ twitchContinuation: any TwitchContinuation, timeout: Duration?, | ||
run task: @escaping () async throws -> Void | ||
) async throws { | ||
continuations.append(twitchContinuation) | ||
|
||
let timeoutTask = Task { | ||
if let timeout { | ||
try await Task.sleep(for: timeout) | ||
throw IRCError.timedOut | ||
} | ||
} | ||
|
||
// ensure that continuations get cleaned up from the list after they were completed | ||
defer { continuations.removeAll(where: { $0 === twitchContinuation }) } | ||
|
||
try await withCheckedThrowingContinuation { continuation in | ||
Task { | ||
await twitchContinuation.setContinuation(continuation) | ||
|
||
try await task() | ||
|
||
do { try await timeoutTask.value } catch is IRCError { | ||
await twitchContinuation.cancel(throwing: IRCError.timedOut) | ||
} | ||
} | ||
} | ||
} | ||
|
||
mutating func completeAny(matching message: IncomingMessage) async { | ||
for continuation in continuations { | ||
_ = await continuation.check(message: message) | ||
} | ||
} | ||
} |
51 changes: 51 additions & 0 deletions
51
Sources/Twitch/Chat/IRC/TwitchContinuations/JoinContinuation.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import TwitchIRC | ||
|
||
internal actor JoinContinuation: TwitchContinuation { | ||
private var continuation: CheckedContinuation<Void, Error>? | ||
private let channel: String | ||
|
||
internal func check(message: IncomingMessage) async -> Bool { | ||
return checkNotice(message: message) || checkJoin(message: message) | ||
} | ||
|
||
private func checkNotice(message: IncomingMessage) -> Bool { | ||
guard case .notice(let notice) = message else { return false } | ||
guard case .local(let channel, _, let noticeId) = notice.kind else { | ||
return false | ||
} | ||
|
||
guard channel == self.channel else { return false } | ||
|
||
switch noticeId { | ||
case .msgChannelSuspended: | ||
continuation?.resume(throwing: IRCError.channelSuspended(channel)) | ||
case .msgBanned: | ||
continuation?.resume(throwing: IRCError.bannedFromChannel(channel)) | ||
default: return false | ||
} | ||
|
||
continuation = nil | ||
return true | ||
} | ||
|
||
private func checkJoin(message: IncomingMessage) -> Bool { | ||
guard case .join(let join) = message else { return false } | ||
guard join.channel == self.channel else { return false } | ||
|
||
continuation?.resume() | ||
continuation = nil | ||
|
||
return true | ||
} | ||
|
||
internal func cancel(throwing error: IRCError) { | ||
continuation?.resume(throwing: error) | ||
continuation = nil | ||
} | ||
|
||
internal func setContinuation( | ||
_ continuation: CheckedContinuation<Void, Error> | ||
) { self.continuation = continuation } | ||
|
||
internal init(channel: String) { self.channel = channel } | ||
} |
Oops, something went wrong.