Skip to content

Commit

Permalink
feat: add IRC connection to chat (#3)
Browse files Browse the repository at this point in the history
* 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
LosFarmosCTL committed Jan 23, 2024
1 parent 05c4e56 commit 3a2ba37
Show file tree
Hide file tree
Showing 19 changed files with 677 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
include:
- os: ubuntu-latest
swift-version: 5.9
- os: macos-latest
- os: macos-13
swift-version: 5.9

steps:
Expand Down
85 changes: 83 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
],
Expand Down
14 changes: 11 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")]),
Expand Down
51 changes: 51 additions & 0 deletions Sources/Twitch/Chat/ChatClient.swift
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: ["#", " "])
}
}
9 changes: 9 additions & 0 deletions Sources/Twitch/Chat/ChatClientOptions.swift
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() {}
}
13 changes: 13 additions & 0 deletions Sources/Twitch/Chat/ChatEvent.swift
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)
}
6 changes: 6 additions & 0 deletions Sources/Twitch/Chat/IRC/Errors/IRCError.swift
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
}
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 }
}
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 }
}
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 Sources/Twitch/Chat/IRC/TwitchContinuations/JoinContinuation.swift
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 }
}
Loading

0 comments on commit 3a2ba37

Please sign in to comment.