├── .gitignore ├── Dockerfile ├── Package.resolved ├── Package.swift ├── Procfile ├── README.md ├── Sources ├── SlackBot │ └── main.swift └── SlackBotKit │ ├── Array+Extensions.swift │ ├── AutoModerator │ └── AutoModerator.swift │ ├── Camillink │ ├── Camillink+Config.swift │ ├── Camillink+Tracking.swift │ └── Camillink.swift │ ├── HelloService.swift │ ├── Karma │ ├── Karma+Adjustment.swift │ ├── Karma+Config.swift │ ├── Karma+Status.swift │ ├── Karma+Top.swift │ └── Karma.swift │ └── Template.swift └── Tests ├── LinuxMain.swift └── SlackBotKitTests ├── AutoModeratorTests.swift ├── CamillinkFixtures ├── Camillink+Fixtures.swift ├── MessageWithDuplicateLink.json ├── MessageWithLink.json ├── MessageWithLinkDelete.json ├── MessageWithLinkUnfurl.json ├── MessageWithPreformattedLink.json ├── ThreadedMesssageWithLink.json └── ThreadedMesssageWithLinkDelete.json ├── CamillinkTests.swift ├── KarmaFixtures ├── Karma+Fixtures.swift ├── MessageWithLink.json └── MessageWithLinkUnfurl.json └── KarmaTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:5.5.3-focal as build 5 | 6 | # Install OS updates and, if needed, sqlite3 7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 8 | && apt-get -q update \ 9 | && apt-get -q dist-upgrade -y \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Set up a build area 13 | WORKDIR /build 14 | 15 | # First just resolve dependencies. 16 | # This creates a cached layer that can be reused 17 | # as long as your Package.swift/Package.resolved 18 | # files do not change. 19 | 20 | # COPY ./Package.* ./ 21 | # RUN ls -a 22 | 23 | # NIO deps 24 | RUN apt-get update && apt-get install -y wget curl 25 | RUN apt-get update && apt-get install -y libssl-dev libicu-dev 26 | 27 | # Copy entire repo into container 28 | COPY . . 29 | RUN swift package resolve -v 30 | 31 | # Build everything, with optimizations 32 | RUN swift build -c release --static-swift-stdlib 33 | 34 | # Switch to the staging area 35 | WORKDIR /staging 36 | 37 | # Copy main executable to staging area 38 | RUN cp -a "$(swift build --package-path /build -c release --show-bin-path)/." ./ 39 | 40 | # Copy resources to staging area 41 | RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -a {} ./ \; 42 | 43 | # Copy any resources from the public directory and views directory if the directories exist 44 | # Ensure that by default, neither the directory nor any of its contents are writable. 45 | # RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true 46 | # RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true 47 | 48 | # ================================ 49 | # Run image 50 | # ================================ 51 | FROM ubuntu:focal 52 | 53 | # Make sure all system packages are up to date. 54 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 55 | && apt-get -q update \ 56 | && apt-get -q dist-upgrade -y \ 57 | && apt-get -q install -y \ 58 | ca-certificates \ 59 | tzdata \ 60 | # If your app or its dependencies import FoundationNetworking, also install `libcurl4`. 61 | libcurl4 \ 62 | # If your app or its dependencies import FoundationXML, also install `libxml2`. 63 | # libxml2 \ 64 | && rm -r /var/lib/apt/lists/* 65 | 66 | 67 | # Create a vapor user and group with /prod as its home directory 68 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /prod vapor 69 | 70 | # Switch to the new home directory 71 | WORKDIR /prod 72 | 73 | # Copy built executable and any staged resources from builder 74 | COPY --from=build --chown=vapor:vapor /staging /prod 75 | 76 | # Ensure all further commands run as the vapor user 77 | USER vapor:vapor 78 | 79 | # Let Docker bind to port 8080 80 | EXPOSE 8080 81 | 82 | # Start the Vapor service when the image is run, default to listening on 8080 in production environment 83 | ENTRYPOINT ["./SlackBot"] 84 | CMD ["serve", "--hostname", "0.0.0.0", "--port", "8080"] 85 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "async-http-client", 6 | "repositoryURL": "https://github.com/swift-server/async-http-client.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "7a4dfe026f6ee0f8ad741b58df74c60af296365d", 10 | "version": "1.9.0" 11 | } 12 | }, 13 | { 14 | "package": "async-kit", 15 | "repositoryURL": "https://github.com/vapor/async-kit.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "e2f741640364c1d271405da637029ea6a33f754e", 19 | "version": "1.11.1" 20 | } 21 | }, 22 | { 23 | "package": "Chameleon", 24 | "repositoryURL": "https://github.com/ChameleonBot/Chameleon.git", 25 | "state": { 26 | "branch": "revamp", 27 | "revision": "e0dc9bbd88977fcb5f7ab306211379994124250c", 28 | "version": null 29 | } 30 | }, 31 | { 32 | "package": "console-kit", 33 | "repositoryURL": "https://github.com/vapor/console-kit.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "75ea3b627d88221440b878e5dfccc73fd06842ed", 37 | "version": "4.2.7" 38 | } 39 | }, 40 | { 41 | "package": "LegibleError", 42 | "repositoryURL": "https://github.com/mxcl/LegibleError.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "bc596702d7ff618c3f90ba480eeb48b3e83a2fbe", 46 | "version": "1.0.6" 47 | } 48 | }, 49 | { 50 | "package": "multipart-kit", 51 | "repositoryURL": "https://github.com/vapor/multipart-kit.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "2dd9368a3c9580792b77c7ef364f3735909d9996", 55 | "version": "4.5.1" 56 | } 57 | }, 58 | { 59 | "package": "redis", 60 | "repositoryURL": "https://github.com/vapor/redis.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "e955843b08064071f465a6b1ca9e04bebad8623a", 64 | "version": "4.6.0" 65 | } 66 | }, 67 | { 68 | "package": "RediStack", 69 | "repositoryURL": "https://gitlab.com/mordil/RediStack.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "b277715a02ec3273ede16c2669a5e240fcb4d6b9", 73 | "version": "1.2.2" 74 | } 75 | }, 76 | { 77 | "package": "routing-kit", 78 | "repositoryURL": "https://github.com/vapor/routing-kit.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "5603b81ceb744b8318feab1e60943704977a866b", 82 | "version": "4.3.1" 83 | } 84 | }, 85 | { 86 | "package": "swift-backtrace", 87 | "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "d3e04a9d4b3833363fb6192065b763310b156d54", 91 | "version": "1.3.1" 92 | } 93 | }, 94 | { 95 | "package": "swift-crypto", 96 | "repositoryURL": "https://github.com/apple/swift-crypto.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "067254c79435de759aeef4a6a03e43d087d61312", 100 | "version": "2.0.5" 101 | } 102 | }, 103 | { 104 | "package": "swift-log", 105 | "repositoryURL": "https://github.com/apple/swift-log.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", 109 | "version": "1.4.2" 110 | } 111 | }, 112 | { 113 | "package": "swift-metrics", 114 | "repositoryURL": "https://github.com/apple/swift-metrics.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "eadb828f878fed144387e3845866225bb7082c56", 118 | "version": "2.3.0" 119 | } 120 | }, 121 | { 122 | "package": "swift-nio", 123 | "repositoryURL": "https://github.com/apple/swift-nio.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "d6e3762e0a5f7ede652559f53623baf11006e17c", 127 | "version": "2.39.0" 128 | } 129 | }, 130 | { 131 | "package": "swift-nio-extras", 132 | "repositoryURL": "https://github.com/apple/swift-nio-extras.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "f73ca5ee9c6806800243f1ac415fcf82de9a4c91", 136 | "version": "1.10.2" 137 | } 138 | }, 139 | { 140 | "package": "swift-nio-http2", 141 | "repositoryURL": "https://github.com/apple/swift-nio-http2.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "50c25c132b140e62b45e90b5a76f13ded02c8a46", 145 | "version": "1.20.1" 146 | } 147 | }, 148 | { 149 | "package": "swift-nio-ssl", 150 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "b5260a31c2a72a89fa684f5efb3054d8725a2316", 154 | "version": "2.18.0" 155 | } 156 | }, 157 | { 158 | "package": "swift-nio-transport-services", 159 | "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", 160 | "state": { 161 | "branch": null, 162 | "revision": "8ab824b140d0ebcd87e9149266ddc353e3705a3e", 163 | "version": "1.11.4" 164 | } 165 | }, 166 | { 167 | "package": "vapor", 168 | "repositoryURL": "https://github.com/vapor/vapor.git", 169 | "state": { 170 | "branch": null, 171 | "revision": "5861bf9e2cff2c4cb0dcfb0c15ecfaa8bc5630e0", 172 | "version": "4.55.3" 173 | } 174 | }, 175 | { 176 | "package": "websocket-kit", 177 | "repositoryURL": "https://github.com/vapor/websocket-kit.git", 178 | "state": { 179 | "branch": null, 180 | "revision": "e32033ad3c68ebec1b761bc961be7bd56bad02f8", 181 | "version": "2.3.1" 182 | } 183 | } 184 | ] 185 | }, 186 | "version": 1 187 | } 188 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "SlackBot", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | products: [ 10 | .executable(name: "SlackBot", targets: ["SlackBot"]), 11 | .library(name: "SlackBotKit", targets: ["SlackBotKit"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/ChameleonBot/Chameleon.git", .branch("revamp")), 15 | .package(url: "https://github.com/mxcl/LegibleError.git", from: "1.0.0"), 16 | ], 17 | targets: [ 18 | .target(name: "SlackBot", dependencies: [ 19 | "SlackBotKit", 20 | .product(name: "VaporProviders", package: "Chameleon"), 21 | .product(name: "LegibleError", package: "LegibleError"), 22 | ]), 23 | .target(name: "SlackBotKit", dependencies: [ 24 | .product(name: "ChameleonKit", package: "Chameleon") 25 | ]), 26 | .testTarget(name: "SlackBotKitTests", dependencies: [ 27 | "SlackBotKit", 28 | .product(name: "ChameleonTestKit", package: "Chameleon") 29 | ]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: SlackBot serve --hostname 0.0.0.0 --port $PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Camille 2 | 3 | ## Getting Started 4 | 5 | ### Set up the project 6 | 7 | - Clone the project. 8 | - Double click Package.swift. 9 | - [Create a Slack app](https://api.slack.com/apps). Click "Add features and functionality," and add the "Bots" feature. 10 | - Set up [ngrok](https://ngrok.com) for development. Run `ngrok http http://0.0.0.0:8080` to expose your local server to the internet. 11 | - Copy the URL ngrok gives you (it'll be something like `http://abc123.ngrok.io`). 12 | - Go back to your Slack app's settings and add the ngrok URL in the Slack app permissions page under "Redirect URIs" with `/oauth` at the end of it (so, in our example, `http://abc123.ngrok.io/oauth`) 13 | - If you're developing against the production version of Camille setup [redis](https://redis.io). [Docker](https://docs.docker.com/docker-for-mac/install/) is an easy way to do this. If you have docker installed, run `docker run --name redis -p 6379:6379 -d redis`. Otherwise you can use `MemoryStorage` in place of redis. 14 | - Set the project's environment variables. Click the scheme dropdown, and hit "edit scheme." 15 | - Set the `STORAGE_URL` environment variable to the redis URL (`redis://127.0.0.1:6379` by default) 16 | - Set `CLIENT_ID` and `CLIENT_SECRET` to the IDs that Slack shows on your App page. (Alternatively message @mergesort for some development credentials you can use for testing an integration.) 17 | - Set `REDIRECT_URI` to the redirect URL we set earlier ( `http://abc123.ngrok.io/oauth`, in our example) 18 | - Run the project 19 | - Visit `http://0.0.0.0:8080/login` and authorize the app. 20 | - You should be online now! 21 | -------------------------------------------------------------------------------- /Sources/SlackBot/main.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import Foundation 3 | import LegibleError 4 | import SlackBotKit 5 | import VaporProviders 6 | 7 | let env = Environment() 8 | let storageUrl = URL(string: try env.get(forKey: "STORAGE_URL"))! 9 | let storage = try! RedisStorage(url: storageUrl) 10 | 11 | let bot = try SlackBot 12 | .vaporBased( 13 | verificationToken: try env.get(forKey: "VERIFICATION_TOKEN"), 14 | accessToken: try env.get(forKey: "ACCESS_TOKEN") 15 | ) 16 | .enableHello() 17 | .enableKarma(config: .default(), storage: storage) 18 | .enableCamillink(config: .default(), storage: storage) 19 | .enableAutoModerator(config: .default()) 20 | 21 | //bot.listen(for: .error) { bot, error in 22 | // let channel = Identifier(rawValue: "#camille-ionaires") 23 | // try bot.perform(.speak(in: channel, "\("Error: ", .bold) \(error.legibleLocalizedDescription)")) 24 | //} 25 | 26 | try bot.start() 27 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Array+Extensions.swift: -------------------------------------------------------------------------------- 1 | extension Array where Element: Hashable { 2 | func removeDuplicates() -> Array { 3 | var result = Array() 4 | var seen: Set = [] 5 | 6 | for item in self { 7 | guard seen.insert(item).inserted else { continue } 8 | 9 | result.append(item) 10 | } 11 | 12 | return result 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/AutoModerator/AutoModerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ChameleonKit 3 | 4 | /// A service that observes every message in a public channel, determines whether it requires an automatic moderator 5 | /// response based on the message contents and replies with an in-line thread. 6 | public enum AutoModerator { 7 | public struct Config { 8 | var triggerPhrases: [Parser] 9 | 10 | init(triggerPhrases: [Parser]) { 11 | self.triggerPhrases = triggerPhrases 12 | } 13 | 14 | public static func `default`() -> Config { 15 | return .init(triggerPhrases: [ 16 | "you guys", 17 | "thanks guys", 18 | "hi guys", 19 | "hey guys", 20 | ]) 21 | } 22 | } 23 | } 24 | 25 | extension SlackBot { 26 | public func enableAutoModerator(config: AutoModerator.Config) -> SlackBot { 27 | listen(for: .message) { bot, message in 28 | guard message.user != bot.me.id else { return } 29 | guard !(message.subtype == .thread_broadcast && message.hidden) else { return } 30 | 31 | try message.matching(.anyOf(config.triggerPhrases)) { _ in 32 | let heyGuysLink = URL(string: "https://iosfolks.com/hey-guys")! 33 | let response: MarkdownString = "To promote inclusivity we ask people to use an alternative to guys such as y’all or folks. We all make mistakes so don't overthink it, you can learn more about \("this message", heyGuysLink) or Camille in our Community Guide." 34 | try bot.perform(.respond(to: message, .threaded, with: response)) 35 | } 36 | } 37 | 38 | return self 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Camillink/Camillink+Config.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import Foundation 3 | 4 | extension SlackBot.Camillink { 5 | public struct Config { 6 | /// How many days have to have passed before a prompt. No limit if nil. 7 | public var recencyLimitInDays: Int? 8 | 9 | /// Whether camille should prompt if a message has a link and a reference to the channel it was originally posted in. 10 | /// eg: "hey check out http://example.com that's being discussed in #exampletalk 11 | /// if true, do not post a message 12 | public var silentCrossLink: Bool 13 | 14 | /// Whether camille should prompt if a message has a link that was posted earlier in the same channel. 15 | /// if true, do not post a message when a link is in the same channel 16 | public var silentSameChannel: Bool 17 | 18 | /// Function to provide the current date 19 | public var dateFactory: () -> Date = { .init() } 20 | 21 | /// Provides the calendar to use for Date operations 22 | public var calendar: Calendar = .current 23 | 24 | public init(recencyLimitInDays: Int?, silentCrossLink: Bool, silentSameChannel: Bool) { 25 | self.recencyLimitInDays = recencyLimitInDays 26 | self.silentCrossLink = silentCrossLink 27 | self.silentSameChannel = silentSameChannel 28 | } 29 | 30 | public static func `default`() -> Config { 31 | return Config( 32 | recencyLimitInDays: 7, 33 | silentCrossLink: true, 34 | silentSameChannel: false 35 | ) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Camillink/Camillink+Tracking.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import Foundation 3 | 4 | extension SlackBot.Camillink { 5 | 6 | static func tryTrackLink(_ config: Config, _ storage: Storage, _ bot: SlackBot, _ message: Message) throws { 7 | // Check for a web link, make sure it's not Mail.app, etc 8 | let links = message.links() 9 | .filter({ $0.url.absoluteString.hasPrefix("http") }) 10 | .map({ $0.url }) 11 | .compactMap({ self.removeGarbageQueryParameters(url: $0) }) 12 | .removeDuplicates() 13 | 14 | guard !links.isEmpty else { return } 15 | 16 | for link in links { 17 | switch try? storage.get(Record.self, forKey: link.absoluteString, from: Keys.namespace) { 18 | case let record?: 19 | if isRecordExpired(config, record) { 20 | try storage.remove(forKey: link.absoluteString, from: Keys.namespace) 21 | } else if !shouldSilence(link, config, message, record) { 22 | let response: MarkdownString = "\(.wave) That \("link", link) is also being discussed in \("this message", record.permalink) in \(record.channelID)" 23 | try bot.perform(.respond(to: message, .threaded, with: response)) 24 | } 25 | 26 | case nil: // new link 27 | guard !shouldSilenceForAllowListedDomain(link) else { return } 28 | 29 | let permalink = try bot.perform(.permalink(for: message)) 30 | let record = Record(date: config.dateFactory(), channelID: permalink.channel, permalink: permalink.permalink) 31 | try storage.set(forKey: link.absoluteString, from: Keys.namespace, value: record) 32 | } 33 | } 34 | } 35 | 36 | private static func isRecordExpired(_ config: Config, _ record: Record) -> Bool { 37 | guard let dayLimit = config.recencyLimitInDays else { return false } 38 | guard let daysSince = config.calendar.dateComponents([.day], from: record.date, to: config.dateFactory()).day else { return true } 39 | return dayLimit < daysSince 40 | } 41 | 42 | private static func shouldSilence(_ link: URL, _ config: Config, _ message: Message, _ record: Record) -> Bool { 43 | return shouldSilenceForAllowListedDomain(link) 44 | || shouldSilenceForCrossLink(config, message, record) 45 | || shouldSilenceForSameChannel(config, message, record) 46 | } 47 | 48 | private static func shouldSilenceForCrossLink(_ config: Config, _ message: Message, _ record: Record) -> Bool { 49 | guard config.silentCrossLink else { return false } 50 | return message.channels().contains(record.channelID) 51 | } 52 | 53 | private static func shouldSilenceForAllowListedDomain(_ link: URL) -> Bool { 54 | let allowListedHosts = [ 55 | "apple.com", 56 | "developer.apple.com", 57 | "iosdevelopers.slack.com", 58 | "iosfolks.com", 59 | "mlb.tv", 60 | "youtube.com/watch?v=dQw4w9WgXcQ" 61 | ] 62 | 63 | guard let components = URLComponents(url: link, resolvingAgainstBaseURL: false) else { return false } 64 | 65 | return allowListedHosts.contains(where: { $0 == components.host }) 66 | } 67 | 68 | private static func shouldSilenceForSameChannel(_ config: Config, _ message: Message, _ record: Record) -> Bool { 69 | guard config.silentSameChannel else { return false } 70 | return message.channel == record.channelID 71 | } 72 | 73 | private static func removeGarbageQueryParameters(url: URL) -> URL? { 74 | let denyListedQueryParameters = [ 75 | "utm", 76 | "utm_source", 77 | "utm_media", 78 | "utm_campaign", 79 | "utm_medium", 80 | "utm_term", 81 | "utm_content", 82 | "t", 83 | "s", 84 | ] 85 | 86 | guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } 87 | 88 | urlComponents.queryItems?.removeAll(where: { queryItem in 89 | denyListedQueryParameters.contains(where: { $0 == queryItem.name }) 90 | }) 91 | 92 | if let queryItems = urlComponents.queryItems, queryItems.isEmpty { 93 | // An empty queryItems array will retain a trailing ? but nilling it out removes that 94 | urlComponents.queryItems = nil 95 | } 96 | 97 | return urlComponents.url 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Camillink/Camillink.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import Foundation 3 | 4 | extension SlackBot { 5 | public enum Camillink { 6 | enum Keys { 7 | static let namespace = "Camillink" 8 | static let count = "count" 9 | static let user = "user" 10 | } 11 | 12 | struct Record: LosslessStringCodable { 13 | let date: Date 14 | let channelID: Identifier 15 | let permalink: URL 16 | } 17 | } 18 | } 19 | 20 | extension SlackBot { 21 | public func enableCamillink(config: Camillink.Config, storage: Storage) -> SlackBot { 22 | listen(for: .message) { bot, message in 23 | guard message.user != bot.me.id else { return } 24 | guard !message.isUnfurl else { return } 25 | guard !(message.subtype == .thread_broadcast && message.hidden) else { return } 26 | guard message.subtype != .message_changed else { return } 27 | guard message.channel_type != .im else { return } 28 | 29 | try Camillink.tryTrackLink(config, storage, bot, message) 30 | } 31 | 32 | return self 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/HelloService.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | 3 | extension SlackBot { 4 | public func enableHello() -> SlackBot { 5 | listen(for: .message) { bot, message in 6 | let values: [Parser] = ["heya", "hey", "hi", "hello", "gday", "howdy"] 7 | 8 | try message.matching(^.anyOf(values) <* " " && .user(bot.me)^) { greeting in 9 | try bot.perform(.respond(to: message, .inline, with: "well \(greeting) back at you \(message.user)")) 10 | try bot.perform(.react(to: message, with: .wave)) 11 | } 12 | } 13 | return self 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Karma/Karma+Adjustment.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | 3 | struct KarmaModifier { 4 | let update: (Int) -> Int 5 | } 6 | 7 | extension ElementMatcher { 8 | static var karma: ElementMatcher { 9 | // We don't want users to spam the karma system so increments and decrements are capped to 10 10 | let maxKarmaChangeCap = 10 11 | 12 | let plusStart = Parser.literal("++").map { _ in 1 } 13 | let plusExtra = Parser.char("+").many.optional.map { $0?.count ?? 0 } 14 | let plusses = (plusStart && plusExtra).map { $0 + $1 } 15 | let plusPlus = ElementMatcher(^plusses).map { n in KarmaModifier { $0 + min(n, maxKarmaChangeCap) } } 16 | let plusEqualN = ElementMatcher("+=" && optional(.whitespace) *> .integer).map { n in KarmaModifier { $0 + min(n, maxKarmaChangeCap) } } 17 | 18 | let minusStart = Parser.literal("--").map { _ in 1 } 19 | let minusExtra = Parser.char("-").many.optional.map { $0?.count ?? 0 } 20 | let minuses = (minusStart && minusExtra).map { $0 + $1 } 21 | let minusMinus = ElementMatcher(^minuses).map { n in KarmaModifier { $0 - min(n, maxKarmaChangeCap) } } 22 | let minusEqualN = ElementMatcher("-=" && optional(.whitespace) *> .integer).map { n in KarmaModifier { $0 - min(n, maxKarmaChangeCap) } } 23 | 24 | return plusPlus || plusEqualN || minusMinus || minusEqualN 25 | } 26 | } 27 | 28 | extension SlackBot.Karma { 29 | static func tryAdjustments(_ config: Config, _ storage: Storage, _ bot: SlackBot, _ message: Message) throws { 30 | typealias KarmaMatch = (Identifier, KarmaModifier) 31 | 32 | try message.richText().matchingAll([.user, .karma]) { (updates: [KarmaMatch]) in 33 | // consolidate any updates for the same user 34 | var tally: [Identifier: Int] = [:] 35 | 36 | for update in updates { 37 | let current = tally[update.0, default: 0] 38 | tally[update.0] = update.1.update(current) 39 | } 40 | 41 | // filter out unwanted results 42 | tally[message.user] = 0 // remove any 'self-karma' 43 | let validUpdates = tally.filter({ $0.value != 0 }).keys 44 | 45 | guard !validUpdates.isEmpty else { return } 46 | 47 | // perform updates and build response 48 | var responses: [MarkdownString] = [] 49 | for user in validUpdates { 50 | let newTotal: Int 51 | let birthday: Bool 52 | 53 | do { 54 | let currentTotal: Int = try storage.get(forKey: user.rawValue, from: Keys.namespace) 55 | newTotal = currentTotal + tally[user]! 56 | birthday = false 57 | 58 | } catch StorageError.missing { 59 | newTotal = tally[user]! 60 | birthday = true 61 | } 62 | 63 | try storage.set(forKey: user.rawValue, from: Keys.namespace, value: newTotal) 64 | 65 | let commentFormatter = (tally[user]! > 0 66 | ? config.positiveComments.randomElement().map(withBirthday(birthday)) 67 | : config.negativeComments.randomElement().map(withBirthday(birthday)) 68 | ) ?? { "\($0): \($1)" } 69 | 70 | responses.append(commentFormatter(user, newTotal)) 71 | } 72 | 73 | try bot.perform(.respond(to: message, .inline, with: responses.joined(separator: "\n"))) 74 | } 75 | } 76 | } 77 | 78 | private func withBirthday(_ birthday: Bool) -> (@escaping SlackBot.Karma.CommentsFormatter) -> SlackBot.Karma.CommentsFormatter { 79 | return { original in 80 | let birthdayPrefix: MarkdownString = "\(.balloon) " 81 | 82 | return birthday 83 | ? { birthdayPrefix.appending(original($0, $1)) } 84 | : original 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Karma/Karma+Config.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | 3 | extension SlackBot.Karma { 4 | public typealias CommentsFormatter = (Identifier, Int) -> MarkdownString 5 | 6 | public struct Config { 7 | public var topUserLimit: Int 8 | public var positiveComments: [CommentsFormatter] 9 | public var negativeComments: [CommentsFormatter] 10 | 11 | public init(topUserLimit: Int, positiveComments: [CommentsFormatter], negativeComments: [CommentsFormatter]) { 12 | self.topUserLimit = topUserLimit 13 | self.positiveComments = positiveComments 14 | self.negativeComments = negativeComments 15 | } 16 | 17 | public static func `default`() -> Config { 18 | let positiveComments: [CommentsFormatter] = [ 19 | { "You rock \($0)! Now at \($1)." }, 20 | { "Nice job, \($0)! Your karma just bumped to \($1)." }, 21 | { "Awesome \($0)! You’re now at \($1) \(pluralizedScoreString(from: $1))." }, 22 | ] 23 | let negativeComments: [CommentsFormatter] = [ 24 | { "booooo \($0)! Now at \($1)." }, 25 | { "Tssss \($0). Dropped your karma to \($1)." }, 26 | { "Sorry, but I have to drop \($0)’s karma down to \($1) \(pluralizedScoreString(from: $1))." }, 27 | ] 28 | 29 | return Config( 30 | topUserLimit: 10, 31 | positiveComments: positiveComments, 32 | negativeComments: negativeComments 33 | ) 34 | } 35 | } 36 | } 37 | 38 | private extension SlackBot.Karma.Config { 39 | /// Non-localized pluralization. 40 | /// 41 | /// - Parameter score: a camillecoin score. 42 | /// - Returns: `"camillecoin"` if score is 1; otherwise, `"camillecoins"`. 43 | /// - Note: Should be replaced by proper localized pluralization 44 | static func pluralizedScoreString(from score: Int) -> String { 45 | return score == 1 ? "camillecoin" : "camillecoins" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Karma/Karma+Status.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | 3 | extension SlackBot.Karma { 4 | static func trySenderStatus(_ storage: Storage, _ bot: SlackBot, _ message: Message) throws { 5 | try message.richText().matching([^.user(bot.me), "how much karma do I have"]) { 6 | let count = try storage.get(forKey: message.user.rawValue, from: Keys.namespace, or: 0) 7 | 8 | let response: MarkdownString = count == 0 9 | ? "It doesn’t look like you have any karma yet" 10 | : "You have \(count) karma" 11 | 12 | try bot.perform(.respond(to: message, .inline, with: response)) 13 | } 14 | } 15 | 16 | static func tryUserStatus(_ storage: Storage, _ bot: SlackBot, _ message: Message) throws { 17 | try message.richText().matching([^.user(bot.me), "how much karma does", .user, "have"]) { (_: Identifier, user: Identifier) in 18 | let count = try storage.get(forKey: user.rawValue, from: Keys.namespace, or: 0) 19 | 20 | let response: MarkdownString = count == 0 21 | ? "It doesn’t look like you have any karma yet" 22 | : "\(user) has \(count) karma" 23 | 24 | try bot.perform(.respond(to: message, .inline, with: response)) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Karma/Karma+Top.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | 3 | extension SlackBot.Karma { 4 | static func tryTop(_ config: Config, _ storage: Storage, _ bot: SlackBot, _ message: Message) throws { 5 | try message.matching(^.user(bot.me) && " top " *> .integer) { count in 6 | guard count > 0 else { 7 | try bot.perform(.respond(to: message, .inline, with: "Top \(count)? You must work in QA.")) 8 | return 9 | } 10 | 11 | let karma = try storage.keys(in: Keys.namespace) 12 | let values = try storage.getAll(Int.self, forKeys: karma, from: Keys.namespace) 13 | 14 | let leaderboard = zip(karma, values) 15 | .map { (Identifier(rawValue: $0), $1) } 16 | .sorted(by: { $0.1 > $1.1 }) 17 | 18 | guard !leaderboard.isEmpty else { 19 | try bot.perform(.respond(to: message, .inline, with: "No one has any karma yet.")) 20 | return 21 | } 22 | 23 | let prefix: String 24 | if count > config.topUserLimit { prefix = "Yeah, that’s too many. Here’s the top" } 25 | else if leaderboard.count < count { prefix = "We only have" } 26 | else { prefix = "Top" } 27 | 28 | let actualCount = min(min(count, config.topUserLimit), leaderboard.count) 29 | 30 | var response: [MarkdownString] = [ 31 | "\(prefix) \(actualCount)" 32 | ] 33 | 34 | for (position, entry) in leaderboard.prefix(actualCount).enumerated() { 35 | response.append("\(position + 1)) \(entry.0, .bold) : \(entry.1)") 36 | } 37 | 38 | try bot.perform(.respond(to: message, .inline, with: response.joined(separator: "\n"))) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Karma/Karma.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | 3 | extension SlackBot { 4 | public enum Karma { 5 | enum Keys { 6 | static let namespace = "Karma" 7 | static let count = "count" 8 | static let user = "user" 9 | } 10 | } 11 | } 12 | 13 | extension SlackBot { 14 | public func enableKarma(config: Karma.Config, storage: Storage) -> SlackBot { 15 | listen(for: .message) { bot, message in 16 | guard message.user != bot.me.id else { return } 17 | guard !(message.subtype == .thread_broadcast && message.hidden) else { return } 18 | guard !message.isUnfurl else { return } 19 | 20 | try Karma.trySenderStatus(storage, bot, message) 21 | try Karma.tryUserStatus(storage, bot, message) 22 | try Karma.tryAdjustments(config, storage, bot, message) 23 | try Karma.tryTop(config, storage, bot, message) 24 | } 25 | 26 | return self 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/SlackBotKit/Template.swift: -------------------------------------------------------------------------------- 1 | // 2 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SlackBotKit 3 | 4 | // Please test with `swift test --enable-test-discovery` 5 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/AutoModeratorTests.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import ChameleonTestKit 3 | @testable import SlackBotKit 4 | import XCTest 5 | 6 | class AutoModeratorTests: XCTestCase { 7 | func testModerator() throws { 8 | let test = try SlackBot.test() 9 | _ = test.bot.enableAutoModerator(config: .default()) 10 | 11 | let messages = [ 12 | "hey guys", "Hey guys!", 13 | "how are you guys?", 14 | "thank you guys" 15 | ] 16 | 17 | for message in messages { 18 | try test.send(.event(.message(message)), enqueue: [.emptyMessage()]) 19 | } 20 | 21 | // This service triggers a response when the pattern is matched. 22 | // If one of the messages didn't result in a response then the 23 | // 'enqueued' message wont have been used and this assertion will fail 24 | XCTAssertClear(test) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/Camillink+Fixtures.swift: -------------------------------------------------------------------------------- 1 | import ChameleonTestKit 2 | 3 | extension FixtureSource { 4 | static func messageWithLink1() throws -> FixtureSource { try .init(jsonFile: "MessageWithLink") } 5 | static func unfurlLink1() throws -> FixtureSource { try.init(jsonFile: "MessageWithLinkUnfurl") } 6 | static func threadedMessageWithLink1() throws -> FixtureSource { try.init(jsonFile: "ThreadedMesssageWithLink") } 7 | static func deleteThreadedMessageWithLink1() throws -> FixtureSource { try .init(jsonFile: "ThreadedMesssageWithLinkDelete") } 8 | static func deleteMessageWithLink1() throws -> FixtureSource { try .init(jsonFile: "MessageWithLinkDelete") } 9 | 10 | static func messageWithPreformattedLink() throws -> FixtureSource { try .init(jsonFile: "MessageWithPreformattedLink") } 11 | static func messageWithDuplicateLink() throws -> FixtureSource { try .init(jsonFile: "MessageWithDuplicateLink") } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/MessageWithDuplicateLink.json: -------------------------------------------------------------------------------- 1 | { 2 | "team" : "T00000000", 3 | "channel_type" : "channel", 4 | "user" : "U0000000001", 5 | "text" : " ", 6 | "event_ts" : "1592771897.003400", 7 | "ts" : "1592771897.003400", 8 | "blocks" : [ 9 | { 10 | "elements" : [ 11 | { 12 | "type" : "rich_text_section", 13 | "elements" : [ 14 | { 15 | "url" : "https:\/\/twitter.com\/IanKay", 16 | "type" : "link" 17 | }, 18 | { 19 | "text" : " ", 20 | "type" : "text" 21 | }, 22 | { 23 | "url" : "https:\/\/twitter.com\/IanKay", 24 | "type" : "link" 25 | } 26 | ] 27 | } 28 | 29 | ], 30 | "type" : "rich_text", 31 | "block_id" : "EYxRJ" 32 | } 33 | ], 34 | "type" : "message", 35 | "channel" : "C0000000000" 36 | } 37 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/MessageWithLink.json: -------------------------------------------------------------------------------- 1 | { 2 | "team" : "T00000000", 3 | "channel_type" : "channel", 4 | "user" : "U0000000001", 5 | "text" : "", 6 | "event_ts" : "1592771897.003400", 7 | "ts" : "1592771897.003400", 8 | "blocks" : [ 9 | { 10 | "elements" : [ 11 | { 12 | "type" : "rich_text_section", 13 | "elements" : [ 14 | { 15 | "url" : "https:\/\/twitter.com\/IanKay", 16 | "type" : "link" 17 | } 18 | ] 19 | } 20 | ], 21 | "type" : "rich_text", 22 | "block_id" : "EYxRJ" 23 | } 24 | ], 25 | "type" : "message", 26 | "channel" : "C0000000000" 27 | } 28 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/MessageWithLinkDelete.json: -------------------------------------------------------------------------------- 1 | { 2 | "subtype" : "message_deleted", 3 | "channel" : "C0000000000", 4 | "type" : "message", 5 | "channel_type" : "channel", 6 | "previous_message" : { 7 | "attachments" : [ 8 | { 9 | "from_url" : "https:\/\/twitter.com\/IanKay", 10 | "original_url" : "https:\/\/twitter.com\/IanKay", 11 | "text" : "The latest Tweets from Ian Keen (@IanKay). Aussie iOS dev living, working and playing in beautiful Whistler B.C. Tweets are my own. Whistler, British Columbia", 12 | "id" : 1, 13 | "title_link" : "https:\/\/twitter.com\/IanKay", 14 | "service_name" : "twitter.com", 15 | "fallback" : "Ian Keen (@IanKay) | Twitter", 16 | "title" : "Ian Keen (@IanKay) | Twitter", 17 | "service_icon" : "https:\/\/abs.twimg.com\/icons\/apple-touch-icon-192x192.png" 18 | } 19 | ], 20 | "client_msg_id" : "1541c634-28d7-4f8e-8080-bab13b206058", 21 | "text" : "", 22 | "user" : "U0000000001", 23 | "type" : "message", 24 | "ts" : "1592771897.003400", 25 | "team" : "T00000000", 26 | "blocks" : [ 27 | { 28 | "block_id" : "EYxRJ", 29 | "elements" : [ 30 | { 31 | "type" : "rich_text_section", 32 | "elements" : [ 33 | { 34 | "type" : "link", 35 | "url" : "https:\/\/twitter.com\/IanKay" 36 | } 37 | ] 38 | } 39 | ], 40 | "type" : "rich_text" 41 | } 42 | ] 43 | }, 44 | "hidden" : true, 45 | "event_ts" : "1592772508.005400", 46 | "deleted_ts" : "1592771897.003400", 47 | "ts" : "1592772508.005400" 48 | } 49 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/MessageWithLinkUnfurl.json: -------------------------------------------------------------------------------- 1 | { 2 | "type" : "message", 3 | "message" : { 4 | "client_msg_id" : "1541c634-28d7-4f8e-8080-bab13b206058", 5 | "user" : "U0000000001", 6 | "blocks" : [ 7 | { 8 | "elements" : [ 9 | { 10 | "type" : "rich_text_section", 11 | "elements" : [ 12 | { 13 | "url" : "https:\/\/twitter.com\/IanKay", 14 | "type" : "link" 15 | } 16 | ] 17 | } 18 | ], 19 | "type" : "rich_text", 20 | "block_id" : "EYxRJ" 21 | } 22 | ], 23 | "text" : "", 24 | "attachments" : [ 25 | { 26 | "title_link" : "https:\/\/twitter.com\/IanKay", 27 | "title" : "Ian Keen (@IanKay) | Twitter", 28 | "text" : "The latest Tweets from Ian Keen (@IanKay). Aussie iOS dev living, working and playing in beautiful Whistler B.C. Tweets are my own. Whistler, British Columbia", 29 | "fallback" : "Ian Keen (@IanKay) | Twitter", 30 | "from_url" : "https:\/\/twitter.com\/IanKay", 31 | "id" : 1, 32 | "service_icon" : "https:\/\/abs.twimg.com\/icons\/apple-touch-icon-192x192.png", 33 | "original_url" : "https:\/\/twitter.com\/IanKay", 34 | "service_name" : "twitter.com" 35 | } 36 | ], 37 | "type" : "message", 38 | "ts" : "1592771897.003400", 39 | "team" : "T00000000" 40 | }, 41 | "ts" : "1592771898.003500", 42 | "hidden" : true, 43 | "channel_type" : "channel", 44 | "subtype" : "message_changed", 45 | "event_ts" : "1592771898.003500", 46 | "channel" : "C0000000000", 47 | "previous_message" : { 48 | "client_msg_id" : "1541c634-28d7-4f8e-8080-bab13b206058", 49 | "user" : "U0000000001", 50 | "blocks" : [ 51 | { 52 | "elements" : [ 53 | { 54 | "elements" : [ 55 | { 56 | "url" : "https:\/\/twitter.com\/IanKay", 57 | "type" : "link" 58 | } 59 | ], 60 | "type" : "rich_text_section" 61 | } 62 | ], 63 | "type" : "rich_text", 64 | "block_id" : "EYxRJ" 65 | } 66 | ], 67 | "text" : "", 68 | "type" : "message", 69 | "ts" : "1592771897.003400", 70 | "team" : "T00000000" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/MessageWithPreformattedLink.json: -------------------------------------------------------------------------------- 1 | { 2 | "type" : "message", 3 | "ts" : "1593301279.000600", 4 | "team" : "T00000000", 5 | "event_ts" : "1593301279.000600", 6 | "text" : "```hello world ```\n", 7 | "channel_type" : "channel", 8 | "blocks" : [ 9 | { 10 | "block_id" : "ghb", 11 | "type" : "rich_text", 12 | "elements" : [ 13 | { 14 | "type" : "rich_text_preformatted", 15 | "elements" : [ 16 | { 17 | "type" : "text", 18 | "text" : "hello world " 19 | }, 20 | { 21 | "type" : "link", 22 | "url" : "http:\/\/www.google.com" 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | ], 29 | "user" : "U0000000001", 30 | "channel" : "D0000000000" 31 | } 32 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/ThreadedMesssageWithLink.json: -------------------------------------------------------------------------------- 1 | { 2 | "team" : "T00000000", 3 | "channel" : "C0000000000", 4 | "text" : "", 5 | "user" : "U0000000001", 6 | "blocks" : [ 7 | { 8 | "elements" : [ 9 | { 10 | "type" : "rich_text_section", 11 | "elements" : [ 12 | { 13 | "url" : "https:\/\/twitter.com\/IanKay", 14 | "type" : "link" 15 | } 16 | ] 17 | } 18 | ], 19 | "type" : "rich_text", 20 | "block_id" : "6U5Fh" 21 | } 22 | ], 23 | "type" : "message", 24 | "ts" : "1592771909.003600", 25 | "thread_ts" : "1592771894.003200", 26 | "event_ts" : "1592771909.003600", 27 | "parent_user_id" : "U04UAVAEB", 28 | "channel_type" : "channel" 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkFixtures/ThreadedMesssageWithLinkDelete.json: -------------------------------------------------------------------------------- 1 | { 2 | "channel_type" : "channel", 3 | "ts" : "1592771921.003800", 4 | "previous_message" : { 5 | "user" : "U0000000001", 6 | "ts" : "1592771909.003600", 7 | "type" : "message", 8 | "team" : "T00000000", 9 | "client_msg_id" : "fe12cf78-082e-4a2d-beb2-24eadeb0c662", 10 | "text" : "", 11 | "thread_ts" : "1592771894.003200", 12 | "blocks" : [ 13 | { 14 | "elements" : [ 15 | { 16 | "type" : "rich_text_section", 17 | "elements" : [ 18 | { 19 | "url" : "https:\/\/twitter.com\/IanKay", 20 | "type" : "link" 21 | } 22 | ] 23 | } 24 | ], 25 | "type" : "rich_text", 26 | "block_id" : "6U5Fh" 27 | } 28 | ], 29 | "parent_user_id" : "U04UAVAEB" 30 | }, 31 | "channel" : "C0000000000", 32 | "hidden" : true, 33 | "event_ts" : "1592771921.003800", 34 | "deleted_ts" : "1592771909.003600", 35 | "subtype" : "message_deleted", 36 | "type" : "message" 37 | } 38 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/CamillinkTests.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import ChameleonTestKit 3 | @testable import SlackBotKit 4 | import XCTest 5 | 6 | class CamillinkTests: XCTestCase { 7 | 8 | func testLinkTracking() throws { 9 | let test = try SlackBot.test() 10 | let storage = MemoryStorage() 11 | var config: SlackBot.Camillink.Config = .default() 12 | var date = Date() 13 | config.dateFactory = { date } 14 | 15 | _ = test.bot.enableCamillink(config: config, storage: storage) 16 | 17 | // sanity check 18 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), []) 19 | XCTAssertClear(test) 20 | 21 | // messages without links aren't triggering 22 | try test.send(.event(.message([.text("hello world")]))) 23 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), []) 24 | XCTAssertClear(test) 25 | 26 | // initial link 27 | try test.enqueue([.permalink(channelId: "C0000000000", url: URL("https://www.slack.com/permalink/channel1/link1"))]) 28 | try test.send(.event(.messageWithLink1())) 29 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), ["https://twitter.com/IanKay"]) 30 | XCTAssertClear(test) 31 | 32 | try test.send(.event(.unfurlLink1())) 33 | XCTAssertClear(test) 34 | 35 | // same link, same channel, 1 second later 36 | date.addTimeInterval(1) 37 | try test.enqueue([.emptyMessage()]) 38 | try test.send(.event(.messageWithLink1())) 39 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), ["https://twitter.com/IanKay"]) 40 | XCTAssertClear(test) 41 | 42 | // same link, thread 43 | try test.enqueue([.emptyMessage()]) 44 | try test.send(.event(.threadedMessageWithLink1())) 45 | XCTAssertClear(test) 46 | 47 | // delete link from thread 48 | try test.send(.event(.deleteThreadedMessageWithLink1())) 49 | XCTAssertClear(test) 50 | 51 | // same link, different channel, 1 second later 52 | date.addTimeInterval(1) 53 | try test.enqueue([.emptyMessage()]) 54 | try test.send(.event(.message([.link(url: URL("https://twitter.com/IanKay"))]))) 55 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), ["https://twitter.com/IanKay"]) 56 | XCTAssertClear(test) 57 | 58 | // same link, ensuring that an allowlisted domain does not trigger Camille, 1 second later 59 | date.addTimeInterval(1) 60 | try test.send(.event(.message([.link(url: URL("https://apple.com/"))]))) 61 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), ["https://twitter.com/IanKay"]) 62 | XCTAssertClear(test) 63 | 64 | // same link, ensuring a denylisted query parameter is stripped, 1 second later 65 | date.addTimeInterval(1) 66 | try test.enqueue([.emptyMessage()]) 67 | try test.send(.event(.message([.link(url: URL("https://twitter.com/IanKay?s=20"))]))) 68 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), ["https://twitter.com/IanKay"]) 69 | XCTAssertClear(test) 70 | 71 | // same link, different channel, after expiration 72 | let day = 60 * 60 * 24 73 | date.addTimeInterval(TimeInterval(day * (config.recencyLimitInDays! + 1))) 74 | try test.send(.event(.message([.link(url: URL("https://twitter.com/IanKay"))]))) 75 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), []) 76 | XCTAssertClear(test) 77 | } 78 | 79 | func testLinkTracking_EdgeCases_LinksInCodeBlocks() throws { 80 | let test = try SlackBot.test() 81 | let storage = MemoryStorage() 82 | _ = test.bot.enableCamillink(config: .default(), storage: storage) 83 | 84 | try test.send(.event(.messageWithPreformattedLink())) 85 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), []) 86 | XCTAssertClear(test) 87 | } 88 | 89 | func testLinkTracking_EdgeCases_DuplicateLink() throws { 90 | let test = try SlackBot.test() 91 | let storage = MemoryStorage() 92 | _ = test.bot.enableCamillink(config: .default(), storage: storage) 93 | 94 | // only 1 permalink should be requested 95 | try test.enqueue([.permalink(channelId: "C0000000000", url: URL("https://www.slack.com/permalink/channel1/link1"))]) 96 | 97 | try test.send(.event(.messageWithDuplicateLink())) 98 | try XCTAssertEqual(storage.keys(in: SlackBot.Camillink.Keys.namespace), ["https://twitter.com/IanKay"]) 99 | XCTAssertClear(test) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/KarmaFixtures/Karma+Fixtures.swift: -------------------------------------------------------------------------------- 1 | import ChameleonTestKit 2 | 3 | extension FixtureSource { 4 | static func karmaMessageWithLink1() throws -> FixtureSource { try .init(jsonFile: "MessageWithLink") } 5 | static func karmaUnfurlLink1() throws -> FixtureSource { try.init(jsonFile: "MessageWithLinkUnfurl") } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/KarmaFixtures/MessageWithLink.json: -------------------------------------------------------------------------------- 1 | { 2 | "user" : "U0000000001", 3 | "blocks" : [ 4 | { 5 | "block_id" : "dyBy", 6 | "type" : "rich_text", 7 | "elements" : [ 8 | { 9 | "elements" : [ 10 | { 11 | "url" : "https:\/\/developer.apple.com\/", 12 | "type" : "link" 13 | }, 14 | { 15 | "text" : " ", 16 | "type" : "text" 17 | }, 18 | { 19 | "user_id" : "U0000000002", 20 | "type" : "user" 21 | }, 22 | { 23 | "text" : " ++", 24 | "type" : "text" 25 | } 26 | ], 27 | "type" : "rich_text_section" 28 | } 29 | ] 30 | } 31 | ], 32 | "event_ts" : "1593110391.000600", 33 | "type" : "message", 34 | "channel_type" : "im", 35 | "ts" : "1593110391.000600", 36 | "channel" : "D0000000000", 37 | "text" : " <@U0000000002> ++", 38 | "team" : "T00000000" 39 | } 40 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/KarmaFixtures/MessageWithLinkUnfurl.json: -------------------------------------------------------------------------------- 1 | { 2 | "previous_message" : { 3 | "type" : "message", 4 | "user" : "U0000000001", 5 | "blocks" : [ 6 | { 7 | "elements" : [ 8 | { 9 | "type" : "rich_text_section", 10 | "elements" : [ 11 | { 12 | "type" : "link", 13 | "url" : "https:\/\/developer.apple.com\/" 14 | }, 15 | { 16 | "text" : " ", 17 | "type" : "text" 18 | }, 19 | { 20 | "user_id" : "U0000000002", 21 | "type" : "user" 22 | }, 23 | { 24 | "type" : "text", 25 | "text" : " ++" 26 | } 27 | ] 28 | } 29 | ], 30 | "type" : "rich_text", 31 | "block_id" : "dyBy" 32 | } 33 | ], 34 | "text" : " <@U2CN0E3PW> ++", 35 | "ts" : "1593110391.000600", 36 | "team" : "T00000000", 37 | "client_msg_id" : "cf00e094-d48d-4db2-826e-f3560c46f41a" 38 | }, 39 | "channel_type" : "im", 40 | "hidden" : true, 41 | "channel" : "D0000000000", 42 | "event_ts" : "1593110393.000700", 43 | "message" : { 44 | "blocks" : [ 45 | { 46 | "elements" : [ 47 | { 48 | "type" : "rich_text_section", 49 | "elements" : [ 50 | { 51 | "url" : "https:\/\/developer.apple.com\/", 52 | "type" : "link" 53 | }, 54 | { 55 | "type" : "text", 56 | "text" : " " 57 | }, 58 | { 59 | "type" : "user", 60 | "user_id" : "U0000000002" 61 | }, 62 | { 63 | "type" : "text", 64 | "text" : " ++" 65 | } 66 | ] 67 | } 68 | ], 69 | "type" : "rich_text", 70 | "block_id" : "dyBy" 71 | } 72 | ], 73 | "ts" : "1593110391.000600", 74 | "type" : "message", 75 | "attachments" : [ 76 | { 77 | "service_icon" : "https:\/\/developer.apple.com\/favicon.ico", 78 | "id" : 1, 79 | "original_url" : "https:\/\/developer.apple.com\/", 80 | "title_link" : "https:\/\/developer.apple.com\/", 81 | "fallback" : "Apple Developer", 82 | "title" : "Apple Developer", 83 | "from_url" : "https:\/\/developer.apple.com\/", 84 | "service_name" : "developer.apple.com", 85 | "text" : "There’s never been a better time to develop for Apple Platforms." 86 | } 87 | ], 88 | "team" : "T00000000", 89 | "client_msg_id" : "cf00e094-d48d-4db2-826e-f3560c46f41a", 90 | "user" : "U0000000001", 91 | "text" : " <@U2CN0E3PW> ++" 92 | }, 93 | "ts" : "1593110393.000700", 94 | "subtype" : "message_changed", 95 | "type" : "message" 96 | } 97 | -------------------------------------------------------------------------------- /Tests/SlackBotKitTests/KarmaTests.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import ChameleonTestKit 3 | @testable import SlackBotKit 4 | import XCTest 5 | 6 | class KarmaTests: XCTestCase { 7 | func testKarma_NoMatches() throws { 8 | let test = try SlackBot.test() 9 | let storage = MemoryStorage() 10 | _ = test.bot.enableKarma(config: .default(), storage: storage) 11 | 12 | try test.send(.event(.message("hello"))) 13 | try test.send(.event(.message([.user("1"), .text("hey!!")]))) 14 | 15 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), []) 16 | XCTAssertClear(test) 17 | } 18 | 19 | func testKarma_NormalMatches_WhitespaceVariants() throws { 20 | let test = try SlackBot.test() 21 | let storage = MemoryStorage() 22 | _ = test.bot.enableKarma(config: .default(), storage: storage) 23 | 24 | try test.send(.event(.message([.user("1"), .text(" ++")])), enqueue: [.emptyMessage()]) 25 | try test.send(.event(.message([.user("1"), .text("++")])), enqueue: [.emptyMessage()]) 26 | try test.send(.event(.message([.user("1"), .text("++ ")])), enqueue: [.emptyMessage()]) 27 | try test.send(.event(.message([.user("1"), .text(" ++ ")])), enqueue: [.emptyMessage()]) 28 | 29 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), ["1"]) 30 | try XCTAssertEqual(storage.get(forKey: "1", from: SlackBot.Karma.Keys.namespace), 4) 31 | XCTAssertClear(test) 32 | } 33 | 34 | func testKarma_MultipleMatches() throws { 35 | let test = try SlackBot.test() 36 | let storage = MemoryStorage() 37 | _ = test.bot.enableKarma(config: .default(), storage: storage) 38 | 39 | try test.send( 40 | .event(.message([ 41 | .text("thanks "), .user("1"), .text(" ++ and also "), .user("2"), .text("++") 42 | ])), 43 | enqueue: [.emptyMessage()] 44 | ) 45 | 46 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace).sorted(), ["1", "2"]) 47 | try XCTAssertEqual(storage.get(forKey: "1", from: SlackBot.Karma.Keys.namespace), 1) 48 | try XCTAssertEqual(storage.get(forKey: "2", from: SlackBot.Karma.Keys.namespace), 1) 49 | XCTAssertClear(test) 50 | } 51 | 52 | func testKarma_MultipleMatches_Consolidation() throws { 53 | let test = try SlackBot.test() 54 | let storage = MemoryStorage() 55 | _ = test.bot.enableKarma(config: .default(), storage: storage) 56 | 57 | try test.send( 58 | .event(.message([ 59 | .text("thanks "), .user("1"), .text(" ++ and also "), .user("2"), .text("++ "), .user("1"), .text(" ++ "), .user("2"), .text(" --, sorry!") 60 | ])), 61 | enqueue: [.emptyMessage()] 62 | ) 63 | 64 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), ["1"]) 65 | try XCTAssertEqual(storage.get(forKey: "1", from: SlackBot.Karma.Keys.namespace), 2) 66 | XCTAssertClear(test) 67 | } 68 | 69 | func testKarma_EdgeCase_Unfurl() throws { 70 | let test = try SlackBot.test() 71 | let storage = MemoryStorage() 72 | _ = test.bot.enableKarma(config: .default(), storage: storage) 73 | 74 | try test.send(.event(.karmaMessageWithLink1()), enqueue: [.emptyMessage()]) 75 | try test.send(.event(.karmaUnfurlLink1())) 76 | 77 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), ["U0000000002"]) 78 | try XCTAssertEqual(storage.get(forKey: "U0000000002", from: SlackBot.Karma.Keys.namespace), 1) 79 | XCTAssertClear(test) 80 | } 81 | 82 | func testKarma_EdgeCase_FalsePositives() throws { 83 | let test = try SlackBot.test() 84 | let storage = MemoryStorage() 85 | _ = test.bot.enableKarma(config: .default(), storage: storage) 86 | 87 | try test.send( 88 | .event(.message([ 89 | .text("Hey "), .user("1"), .text(" have you used C++?") 90 | ])) 91 | ) 92 | 93 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), []) 94 | XCTAssertClear(test) 95 | } 96 | 97 | func testKarma_EdgeCase_SelfKarma() throws { 98 | let test = try SlackBot.test() 99 | let storage = MemoryStorage() 100 | _ = test.bot.enableKarma(config: .default(), storage: storage) 101 | 102 | try test.send(.event(.message(userId: "1", [.user("1"), .text(" ++")]))) 103 | 104 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), []) 105 | XCTAssertClear(test) 106 | } 107 | 108 | func testKarma_EdgeCase_Punctuation() throws { 109 | let test = try SlackBot.test() 110 | let storage = MemoryStorage() 111 | _ = test.bot.enableKarma(config: .default(), storage: storage) 112 | 113 | try test.send(.event(.message([.text("(btw, "), .user("1"), .text("++)")])), enqueue: [.emptyMessage()]) 114 | 115 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), ["1"]) 116 | try XCTAssertEqual(storage.get(forKey: "1", from: SlackBot.Karma.Keys.namespace), 1) 117 | XCTAssertClear(test) 118 | } 119 | 120 | func testKarma_EdgeCase_LeadingMention() throws { 121 | let test = try SlackBot.test() 122 | let storage = MemoryStorage() 123 | _ = test.bot.enableKarma(config: .default(), storage: storage) 124 | 125 | try test.send(.event(.message([.user("1"), .text(" words ") ,.user("1"), .text(" ++")])), enqueue: [.emptyMessage()]) 126 | 127 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), ["1"]) 128 | try XCTAssertEqual(storage.get(forKey: "1", from: SlackBot.Karma.Keys.namespace), 1) 129 | XCTAssertClear(test) 130 | } 131 | 132 | func testKarma_PointsCapped() throws { 133 | let test = try SlackBot.test() 134 | let storage = MemoryStorage() 135 | _ = test.bot.enableKarma(config: .default(), storage: storage) 136 | 137 | // 20 pluses and minuses should be capped at 10 138 | try test.send(.event(.message([.user("1"), .text(" ++++++++++++++++++++++")])), enqueue: [.emptyMessage()]) 139 | try test.send(.event(.message([.user("2"), .text(" ----------------------")])), enqueue: [.emptyMessage()]) 140 | 141 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), ["1", "2"]) 142 | try XCTAssertEqual(storage.get(forKey: "1", from: SlackBot.Karma.Keys.namespace), 10) 143 | try XCTAssertEqual(storage.get(forKey: "2", from: SlackBot.Karma.Keys.namespace), -10) 144 | XCTAssertClear(test) 145 | } 146 | 147 | } 148 | --------------------------------------------------------------------------------