From 3a2ba37ea27ee89038b48fae36bad088f6e03502 Mon Sep 17 00:00:00 2001 From: LosFarmosCTL <80157503+LosFarmosCTL@users.noreply.github.com> Date: Tue, 23 Jan 2024 17:15:34 +0100 Subject: [PATCH] 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 --- .github/workflows/main.yml | 2 +- Package.resolved | 85 +++++++++++- Package.swift | 14 +- Sources/Twitch/Chat/ChatClient.swift | 51 +++++++ Sources/Twitch/Chat/ChatClientOptions.swift | 9 ++ Sources/Twitch/Chat/ChatEvent.swift | 13 ++ Sources/Twitch/Chat/IRC/Errors/IRCError.swift | 6 + .../AuthenticationContinuation.swift | 38 ++++++ .../CapabilitiesContinuation.swift | 23 ++++ .../ContinuationQueue.swift | 41 ++++++ .../JoinContinuation.swift | 51 +++++++ .../PartContinuation.swift | 27 ++++ .../TwitchContinuation.swift | 9 ++ Sources/Twitch/Chat/IRC/TwitchIRCClient.swift | 125 ++++++++++++++++++ .../Twitch/Chat/IRC/TwitchIRCConnection.swift | 119 +++++++++++++++++ .../Twitch/Chat/IRC/WebSocket/WebSocket.swift | 46 +++++++ .../Chat/IRC/WebSocket/WebSocketError.swift | 7 + Sources/Twitch/Chat/IRCAuthentication.swift | 4 + Tests/TwitchTests/Chat/ChatClientTests.swift | 13 ++ 19 files changed, 677 insertions(+), 6 deletions(-) create mode 100644 Sources/Twitch/Chat/ChatClient.swift create mode 100644 Sources/Twitch/Chat/ChatClientOptions.swift create mode 100644 Sources/Twitch/Chat/ChatEvent.swift create mode 100644 Sources/Twitch/Chat/IRC/Errors/IRCError.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchContinuations/AuthenticationContinuation.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchContinuations/CapabilitiesContinuation.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchContinuations/ContinuationQueue.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchContinuations/JoinContinuation.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchContinuations/PartContinuation.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchContinuations/TwitchContinuation.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchIRCClient.swift create mode 100644 Sources/Twitch/Chat/IRC/TwitchIRCConnection.swift create mode 100644 Sources/Twitch/Chat/IRC/WebSocket/WebSocket.swift create mode 100644 Sources/Twitch/Chat/IRC/WebSocket/WebSocketError.swift create mode 100644 Sources/Twitch/Chat/IRCAuthentication.swift create mode 100644 Tests/TwitchTests/Chat/ChatClientTests.swift diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e09a2cb..ea071f4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ jobs: include: - os: ubuntu-latest swift-version: 5.9 - - os: macos-latest + - os: macos-13 swift-version: 5.9 steps: diff --git a/Package.resolved b/Package.resolved index 2e4c5c3..eada3d9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,13 +9,94 @@ "version" : "3.0.1" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", + "version" : "1.0.5" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "99d066e29effa8845e4761dd3f2f831edfdf8925", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "54c85cb26308b89846d4671f23954dce088da2b0", + "version" : "2.60.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "798c962495593a23fdea0c0c63fd55571d8dff51", + "version" : "1.20.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "3bd9004b9d685ed6b629760fc84903e48efec806", + "version" : "1.29.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9", + "version" : "2.25.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "ebf8b9c365a6ce043bf6e6326a04b15589bd285e", + "version" : "1.20.0" + } + }, { "identity" : "twitchirc", "kind" : "remoteSourceControl", "location" : "https://github.com/MahdiBM/TwitchIRC", "state" : { - "revision" : "c41b28d8bcb6df138363cb287ba53c4538b2dc0c", - "version" : "1.1.1" + "revision" : "6bbdb89f22ecea871182bfe46b2f9e17f6eb535b", + "version" : "1.5.0" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit", + "state" : { + "revision" : "53fe0639a98903858d0196b699720decb42aee7b", + "version" : "2.14.0" } } ], diff --git a/Package.swift b/Package.swift index bdb680d..2e6bbbe 100644 --- a/Package.swift +++ b/Package.swift @@ -4,16 +4,24 @@ import PackageDescription let package = Package( name: "swift-twitch-client", - platforms: [.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8)], + platforms: [.macOS(.v13), .iOS(.v16), .tvOS(.v16), .watchOS(.v9)], products: [.library(name: "Twitch", targets: ["Twitch"])], dependencies: [ .package( url: "https://github.com/WeTransfer/Mocker.git", .upToNextMajor(from: "3.0.1")), - .package(url: "https://github.com/MahdiBM/TwitchIRC", from: "1.0.0"), + .package(url: "https://github.com/MahdiBM/TwitchIRC", from: "1.5.0"), + .package(url: "https://github.com/vapor/websocket-kit", from: "2.14.0"), ], targets: [ - .target(name: "Twitch", dependencies: ["TwitchIRC"]), + .target( + name: "Twitch", + dependencies: [ + "TwitchIRC", + .product( + name: "WebSocketKit", package: "websocket-kit", + condition: .when(platforms: [.linux])), + ]), .testTarget( name: "TwitchTests", dependencies: ["Twitch", "Mocker"], resources: [.process("API/MockResources")]), diff --git a/Sources/Twitch/Chat/ChatClient.swift b/Sources/Twitch/Chat/ChatClient.swift new file mode 100644 index 0000000..0d1a6fb --- /dev/null +++ b/Sources/Twitch/Chat/ChatClient.swift @@ -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: ["#", " "]) + } +} diff --git a/Sources/Twitch/Chat/ChatClientOptions.swift b/Sources/Twitch/Chat/ChatClientOptions.swift new file mode 100644 index 0000000..a958c7e --- /dev/null +++ b/Sources/Twitch/Chat/ChatClientOptions.swift @@ -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() {} +} diff --git a/Sources/Twitch/Chat/ChatEvent.swift b/Sources/Twitch/Chat/ChatEvent.swift new file mode 100644 index 0000000..9936b7b --- /dev/null +++ b/Sources/Twitch/Chat/ChatEvent.swift @@ -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) +} diff --git a/Sources/Twitch/Chat/IRC/Errors/IRCError.swift b/Sources/Twitch/Chat/IRC/Errors/IRCError.swift new file mode 100644 index 0000000..355d4be --- /dev/null +++ b/Sources/Twitch/Chat/IRC/Errors/IRCError.swift @@ -0,0 +1,6 @@ +public enum IRCError: Error { + case loginFailed + case channelSuspended(_ channel: String) + case bannedFromChannel(_ channel: String) + case timedOut +} diff --git a/Sources/Twitch/Chat/IRC/TwitchContinuations/AuthenticationContinuation.swift b/Sources/Twitch/Chat/IRC/TwitchContinuations/AuthenticationContinuation.swift new file mode 100644 index 0000000..6618597 --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchContinuations/AuthenticationContinuation.swift @@ -0,0 +1,38 @@ +import TwitchIRC + +internal actor AuthenticationContinuation: TwitchContinuation { + private var continuation: CheckedContinuation? + + 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 + ) { self.continuation = continuation } +} diff --git a/Sources/Twitch/Chat/IRC/TwitchContinuations/CapabilitiesContinuation.swift b/Sources/Twitch/Chat/IRC/TwitchContinuations/CapabilitiesContinuation.swift new file mode 100644 index 0000000..cf7b07b --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchContinuations/CapabilitiesContinuation.swift @@ -0,0 +1,23 @@ +import TwitchIRC + +internal actor CapabilitiesContinuation: TwitchContinuation { + private var continuation: CheckedContinuation? + + 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 + ) { self.continuation = continuation } +} diff --git a/Sources/Twitch/Chat/IRC/TwitchContinuations/ContinuationQueue.swift b/Sources/Twitch/Chat/IRC/TwitchContinuations/ContinuationQueue.swift new file mode 100644 index 0000000..ce9486e --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchContinuations/ContinuationQueue.swift @@ -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) + } + } +} diff --git a/Sources/Twitch/Chat/IRC/TwitchContinuations/JoinContinuation.swift b/Sources/Twitch/Chat/IRC/TwitchContinuations/JoinContinuation.swift new file mode 100644 index 0000000..b617764 --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchContinuations/JoinContinuation.swift @@ -0,0 +1,51 @@ +import TwitchIRC + +internal actor JoinContinuation: TwitchContinuation { + private var continuation: CheckedContinuation? + 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 + ) { self.continuation = continuation } + + internal init(channel: String) { self.channel = channel } +} diff --git a/Sources/Twitch/Chat/IRC/TwitchContinuations/PartContinuation.swift b/Sources/Twitch/Chat/IRC/TwitchContinuations/PartContinuation.swift new file mode 100644 index 0000000..146b0c9 --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchContinuations/PartContinuation.swift @@ -0,0 +1,27 @@ +import TwitchIRC + +internal actor PartContinuation: TwitchContinuation { + private var continuation: CheckedContinuation? + private let channel: String + + internal func check(message: IncomingMessage) async -> Bool { + guard case .part(let part) = message else { return false } + guard part.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 + ) { self.continuation = continuation } + + internal init(channel: String) { self.channel = channel } +} diff --git a/Sources/Twitch/Chat/IRC/TwitchContinuations/TwitchContinuation.swift b/Sources/Twitch/Chat/IRC/TwitchContinuations/TwitchContinuation.swift new file mode 100644 index 0000000..5ff4fc4 --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchContinuations/TwitchContinuation.swift @@ -0,0 +1,9 @@ +import Foundation +import TwitchIRC + +internal protocol TwitchContinuation: Actor, Identifiable { + func check(message: IncomingMessage) async -> Bool + func setContinuation(_ continuation: CheckedContinuation) + + func cancel(throwing error: IRCError) +} diff --git a/Sources/Twitch/Chat/IRC/TwitchIRCClient.swift b/Sources/Twitch/Chat/IRC/TwitchIRCClient.swift new file mode 100644 index 0000000..12aa99a --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchIRCClient.swift @@ -0,0 +1,125 @@ +import Foundation +import TwitchIRC + +internal class TwitchIRCClient { + private let options: ChatClientOptions + + private var writeConnection: TwitchIRCConnection + private var readConnections: [TwitchIRCConnection] + + private let authentication: IRCAuthentication + + private var messageSink: + AsyncThrowingStream.Continuation? + + init(with authentication: IRCAuthentication, options: ChatClientOptions) { + self.options = options + + self.authentication = authentication + + self.writeConnection = TwitchIRCConnection(with: authentication) + + let initialReadConnection = TwitchIRCConnection(with: authentication) + self.readConnections = [initialReadConnection] + } + + internal func connect() async throws -> AsyncThrowingStream< + IncomingMessage, Error + > { + try await connectWriteConnection(self.writeConnection) + + for connection in self.readConnections { + try await connectReadConnection(connection) + } + + return AsyncThrowingStream { continuation in + self.messageSink = continuation + } + } + + internal func disconnect() { + self.writeConnection.disconnect(with: .normalClosure) + + for connection in self.readConnections { + connection.disconnect(with: .normalClosure) + } + + self.messageSink?.finish() + } + + internal func join(to channel: String) async throws { + if self.readConnections.first(where: { $0.joinedChannels.contains(channel) } + ) != nil { + return + } else if let freeConnection = self.readConnections.first(where: { + $0.joinedChannels.count < 90 + }) { + try await freeConnection.join(to: channel, timeout: options.joinTimeout) + } else { + let newConnection = self.addConnection() + try await connectReadConnection(newConnection) + + try await newConnection.join(to: channel, timeout: options.joinTimeout) + } + } + + internal func part(from channel: String) async throws { + if let connection = self.readConnections.first(where: { + $0.joinedChannels.contains(channel) + }) { + try await connection.part(from: channel, timeout: options.partTimeout) + + if connection.joinedChannels.count == 0 { + connection.disconnect(with: .goingAway) + self.readConnections.removeAll(where: { $0 === connection }) + } + } + } + + internal func send( + _ message: String, in channel: String, replyingTo replyMsgId: String?, + nonce: String? + ) async throws { + try await self.writeConnection.privmsg( + to: channel, message: message, replyingTo: replyMsgId, nonce: nonce) + } + + private func addConnection() -> TwitchIRCConnection { + let newConnection = TwitchIRCConnection(with: self.authentication) + self.readConnections.append(newConnection) + + return newConnection + } + + private func connectReadConnection(_ connection: TwitchIRCConnection) + async throws + { + let messages = try await connection.connect(timeout: options.connectTimeout) + + Task { + for try await message in messages { + switch message { + case .clearChat, .userNotice, .clearMessage, .roomState, .userState, + .globalUserState, .privateMessage: + messageSink?.yield(message) + default: break + } + } + } + } + + private func connectWriteConnection(_ connection: TwitchIRCConnection) + async throws + { + let messages = try await connection.connect(timeout: options.connectTimeout) + + Task { + for try await message in messages { + switch message { + case .userState, .notice: messageSink?.yield(message) + default: break + } + } + } + } +} diff --git a/Sources/Twitch/Chat/IRC/TwitchIRCConnection.swift b/Sources/Twitch/Chat/IRC/TwitchIRCConnection.swift new file mode 100644 index 0000000..8848d7f --- /dev/null +++ b/Sources/Twitch/Chat/IRC/TwitchIRCConnection.swift @@ -0,0 +1,119 @@ +import Foundation +import TwitchIRC + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +internal class TwitchIRCConnection { + private let TMI: URL = URL(string: "wss://irc-ws.chat.twitch.tv:443")! + + private let authentication: IRCAuthentication + private let websocket: WebSocket + + private var continuations: ContinuationQueue = ContinuationQueue() + + private(set) var joinedChannels: Set = [] + + init(with authentication: IRCAuthentication) { + websocket = WebSocket(from: TMI) + + self.authentication = authentication + } + + func connect(timeout: Duration?) async throws -> AsyncThrowingStream< + IncomingMessage, Error + > { + let incomingMessages = try websocket.connect() + + let messageStream = AsyncThrowingStream { sink in + Task { + for try await messageString in incomingMessages { + for receivedMessage in IncomingMessage.parse(ircOutput: messageString) + { + guard let message = receivedMessage.message else { continue } + + if case .ping = message { + try await self.websocket.send(OutgoingMessage.pong.serialize()) + continue + } + + await self.continuations.completeAny(matching: message) + sink.yield(message) + } + } + } + } + + try await requestCapabilities(timeout: timeout) + try await authenticate(timeout: timeout) + + return messageStream + } + + func disconnect(with statusCode: URLSessionWebSocketTask.CloseCode) { + websocket.disconnect(with: statusCode) + } + + func privmsg( + to channel: String, message: String, + replyingTo replyMessageId: String? = nil, nonce: String? = nil + ) async throws { + let privmsg = OutgoingMessage.privateMessage( + to: channel, message: message, messageIdToReply: replyMessageId, + clientNonce: nonce) + + try await websocket.send(privmsg.serialize()) + } + + func join(to channel: String, timeout: Duration?) async throws { + try await continuations.register( + JoinContinuation(channel: channel), timeout: timeout + ) { + try await self.websocket.send( + OutgoingMessage.join(to: channel).serialize()) + } + + joinedChannels.insert(channel) + } + + func part(from channel: String, timeout: Duration?) async throws { + try await continuations.register( + PartContinuation(channel: channel), timeout: timeout + ) { + try await self.websocket.send( + OutgoingMessage.part(from: channel).serialize()) + } + + joinedChannels.remove(channel) + } + + private func requestCapabilities(timeout: Duration?) async throws { + try await continuations.register( + CapabilitiesContinuation(), timeout: timeout + ) { + let message = OutgoingMessage.capabilities([.commands, .tags]) + try await self.websocket.send(message.serialize()) + } + } + + private func authenticate(timeout: Duration?) async throws { + try await continuations.register( + AuthenticationContinuation(), timeout: timeout + ) { + var login: String? + + // when connecting anonymously, the PASS message can be omitted + if case .authenticated(let username, let credentials) = self + .authentication + { + login = username + let pass = OutgoingMessage.pass(pass: credentials.oAuth) + try await self.websocket.send(pass.serialize()) + } + + let nick = OutgoingMessage.nick(name: login ?? "justinfan12345") + try await self.websocket.send(nick.serialize()) + } + } +} diff --git a/Sources/Twitch/Chat/IRC/WebSocket/WebSocket.swift b/Sources/Twitch/Chat/IRC/WebSocket/WebSocket.swift new file mode 100644 index 0000000..3a4a637 --- /dev/null +++ b/Sources/Twitch/Chat/IRC/WebSocket/WebSocket.swift @@ -0,0 +1,46 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +internal class WebSocket { + private let websocket: URLSessionWebSocketTask + + init(from url: URL) { + let configuration = URLSessionConfiguration.default + let session = URLSession(configuration: configuration) + self.websocket = session.webSocketTask(with: url) + } + + internal func connect() throws -> AsyncThrowingStream { + if self.websocket.state == .running { + throw WebSocketError.alreadyConnected + } + + self.websocket.resume() + + return AsyncThrowingStream(unfolding: { + let message = try? await self.websocket.receive() + guard let message = message else { throw WebSocketError.disconnected } + + switch message { + case .string(let text): return text + case .data(let data): + self.disconnect(with: .unsupportedData) + throw WebSocketError.invalidMessageReceived(data: data) + @unknown default: + self.disconnect(with: .unsupportedData) + throw WebSocketError.invalidMessageReceived(data: nil) + } + }) + } + + internal func disconnect(with statusCode: URLSessionWebSocketTask.CloseCode) { + self.websocket.cancel(with: statusCode, reason: nil) + } + + internal func send(_ message: String) async throws { + try await self.websocket.send(.string(message)) + } +} diff --git a/Sources/Twitch/Chat/IRC/WebSocket/WebSocketError.swift b/Sources/Twitch/Chat/IRC/WebSocket/WebSocketError.swift new file mode 100644 index 0000000..2f713cd --- /dev/null +++ b/Sources/Twitch/Chat/IRC/WebSocket/WebSocketError.swift @@ -0,0 +1,7 @@ +import Foundation + +internal enum WebSocketError: Error { + case disconnected + case alreadyConnected + case invalidMessageReceived(data: Data?) +} diff --git a/Sources/Twitch/Chat/IRCAuthentication.swift b/Sources/Twitch/Chat/IRCAuthentication.swift new file mode 100644 index 0000000..df190f9 --- /dev/null +++ b/Sources/Twitch/Chat/IRCAuthentication.swift @@ -0,0 +1,4 @@ +public enum IRCAuthentication { + case anonymous + case authenticated(loginName: String, TwitchCredentials) +} diff --git a/Tests/TwitchTests/Chat/ChatClientTests.swift b/Tests/TwitchTests/Chat/ChatClientTests.swift new file mode 100644 index 0000000..cc38baf --- /dev/null +++ b/Tests/TwitchTests/Chat/ChatClientTests.swift @@ -0,0 +1,13 @@ +import Twitch +import XCTest + +class ChatClientTests: XCTestCase { + func testAnonymous() { _ = ChatClient(.anonymous) } + func testAuthenticated() { + _ = ChatClient( + .authenticated( + loginName: "justinfan12345", TwitchCredentials(oAuth: "1234567"))) + } + + func testCapabilities() async throws {} +}