├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── contribute_01.png └── contribute_02.png ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Package.resolved ├── Package.swift ├── Procfile ├── README.md ├── Sources ├── Camille │ └── main.swift └── CamilleServices │ ├── Configs │ ├── Configs+CrossPostService.swift │ ├── Configs+KarmaService.swift │ ├── Configs+TopicService.swift │ ├── Configs+UserJoinService.swift │ └── Configs.swift │ ├── CrossPost │ ├── CrossPostButton.swift │ ├── CrossPostService.swift │ ├── CrossPostServiceConfig.swift │ └── Sequence+MessageDecorator.swift │ ├── EarlyWarning │ ├── EarlyWarning+Config.swift │ └── EarlyWarning.swift │ ├── HelloService.swift │ ├── Karma │ ├── Karma+Adjustment.swift │ ├── Karma+Config.swift │ ├── Karma+Status.swift │ ├── Karma+Top.swift │ └── Karma.swift │ ├── Swift │ └── SwiftService.swift │ ├── TopicService.swift │ └── UserJoinService.swift ├── Tests ├── CamilleTests │ ├── EarlyWarningTests.swift │ ├── KarmaFixtures │ │ ├── Karma+Fixtures.swift │ │ ├── MessageWithLink.json │ │ └── MessageWithLinkUnfurl.json │ └── KarmaTests.swift └── LinuxMain.swift ├── camille.jpg └── docker-compose.yml /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions from everyone. 4 | By participating in this project, 5 | you agree to abide by the iOS Developers HQ [code of conduct]. 6 | 7 | [code of conduct]: http://ios-developers.io/coc/ 8 | 9 | We expect everyone to follow the code of conduct 10 | anywhere in iOS Developers HQ's project codebases, 11 | issue trackers and slack channels. 12 | 13 |
14 | 15 | # Preparing to contribute 16 | Before you can start developing be sure to read through the following first: 17 | 18 | ## Xcode 8 19 | You will first need Xcode 8, you can download it at [Apple Developer Downloads.](https://developer.apple.com/download/) 20 | After your downloded completes drag it into your `Applications` folder to install it. 21 | Finally you need to select Xcode 8 for your `Command Line Tools`, you can do this from Xcode 8 22 | preferences (`cmd` + `,`). Go to the `Locations` tab, under `Command Line Tools` choose `Xcode 8` 23 | 24 | ## Swift 3 25 | You need Swift 3 installed to contribute, I recommend using [swiftenv](https://github.com/kylef/swiftenv). 26 | Once you have `swiftenv` installed run the following commands in a terminal window. 27 | 28 | ``` 29 | swiftenv install DEVELOPMENT-SNAPSHOT-2016-07-25-a 30 | swiftenv global DEVELOPMENT-SNAPSHOT-2016-07-25-a 31 | ``` 32 | 33 | This will install the snapshot and set it as the default installation of Swift 3. 34 | 35 | ## Slack 36 | You will need to [create](https://slack.com/create) a slack team you can use for testing. 37 | Next [configure](https://my.slack.com/services/new/bot) a new bot user for your team. 38 | Make sure you copy the token, you will need it later. 39 | 40 | ## Local setup 41 | First create a fork of the Foobot repo, then clone your forked repo to your local machine. 42 | 43 | Next type 44 | ``` 45 | swift package generate-xcodeproj 46 | ``` 47 | in your terminal, this will download all the dependencies and generate the `.xcodeproj` file. 48 | 49 | Finally, make sure you create a new branch for your contribution then you can open the `.xcodeproj`. 50 | 51 | *Make sure you refer to the [Contributing Code](#contributing-code) section for specifics on what is expected of the code you submit* 52 | 53 | ## Running locally 54 | Ensure you have the right scheme chosen in xcode: 55 | ![](contribute_01.png) 56 | 57 | Now edit the scheme and add a `--token` parameter. This is the bot user token you received when you created the slack bot user earlier. 58 | ![](contribute_02.png) 59 | 60 | You should be able to run the bot now and invite it to channels in your slack team. 61 | 62 | ## Pushing changes 63 | Push to your fork. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 64 | Submit a pull request. 65 | 66 | Others will give constructive feedback. 67 | This is a time for discussion and improvements, 68 | and making the necessary changes will be required before we can 69 | merge the contribution. 70 | 71 |
72 | 73 | # Contributing code 74 | ## Convention 75 | Please make sure you are following the conventions of the existing code. 76 | 77 | ## Branches 78 | Make your branch names short yet descriptive of whats in them. 79 | You should also be keeping them up to date with `master` 80 | 81 | ## Commit messages 82 | Keep them short, under 50 characters is ideal. 83 | Make them [atomic][1], one commit message should pair with one change. 84 | If you have to add an “and” in your commit message, you’ve already committed too much. 85 | 86 | ## Pull requests 87 | Provide a short description of what the code does and why it should be added to the project. 88 | Bullet lists of the contribution are fine and should be easy if your commit messages are also simple. 89 | 90 | ## References: 91 | - [Git Style Guide](https://github.com/agis-/git-style-guide) 92 | - [How to Write a Git Commit Message](http://chris.beams.io/posts/git-commit/) 93 | - [Keep your commits "Atomic"][1] 94 | 95 | [1]: https://www.freshconsulting.com/atomic-commits/ 96 | 97 |
98 | 99 | # Troubleshooting 100 | ### OpenSSL Errors on OSX 101 | If you are unable to build/run locally due to an `openssl` error, you may need to run the following in terminal: 102 | 103 | ``` 104 | brew install openssl 105 | brew link openssl 106 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevelopershq/camille/d8e609c98b9e8007dadca163246820d0ad4e2c0e/.github/ISSUE_TEMPLATE.md -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevelopershq/camille/d8e609c98b9e8007dadca163246820d0ad4e2c0e/.github/PULL_REQUEST_TEMPLATE.md -------------------------------------------------------------------------------- /.github/contribute_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevelopershq/camille/d8e609c98b9e8007dadca163246820d0ad4e2c0e/.github/contribute_01.png -------------------------------------------------------------------------------- /.github/contribute_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevelopershq/camille/d8e609c98b9e8007dadca163246820d0ad4e2c0e/.github/contribute_02.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .DS_Store 4 | *.xcodeproj 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | - osx 4 | language: generic 5 | sudo: required 6 | dist: trusty 7 | osx_image: xcode9.2 8 | before_install: 9 | - eval "$(curl -sL https://raw.githubusercontent.com/ChameleonBot/Scripts/master/configure_ci)" 10 | - eval "$(curl -sL https://swiftenv.fuller.li/install.sh)" 11 | script: 12 | - swift --version 13 | - swift build 14 | - swift test 15 | 16 | -------------------------------------------------------------------------------- /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 ["./Camille"] 84 | CMD ["serve", "--hostname", "0.0.0.0", "--port", "8080"] 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This code is distributed under the terms and conditions of the MIT license. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Chameleon", 6 | "repositoryURL": "https://github.com/ChameleonBot/Chameleon.git", 7 | "state": { 8 | "branch": "revamp", 9 | "revision": "4856b6068e9763ffe881266190ee59fb93061c17", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "Console", 15 | "repositoryURL": "https://github.com/vapor/console.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "74cfbea629d4aac34a97cead2447a6870af1950b", 19 | "version": "3.1.1" 20 | } 21 | }, 22 | { 23 | "package": "Core", 24 | "repositoryURL": "https://github.com/vapor/core.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "89c6989fd8b1e08acfd198afba1c38971bb814b2", 28 | "version": "3.10.1" 29 | } 30 | }, 31 | { 32 | "package": "Crypto", 33 | "repositoryURL": "https://github.com/vapor/crypto.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "105c2f875588bf40dd24c00cef3644bf8e327770", 37 | "version": "3.4.1" 38 | } 39 | }, 40 | { 41 | "package": "DatabaseKit", 42 | "repositoryURL": "https://github.com/vapor/database-kit.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "8f352c8e66dab301ab9bfef912a01ce1361ba1e4", 46 | "version": "1.3.3" 47 | } 48 | }, 49 | { 50 | "package": "HTTP", 51 | "repositoryURL": "https://github.com/vapor/http.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "0464b715a4b59f54078bcf7a4b424767b03db5a5", 55 | "version": "3.4.0" 56 | } 57 | }, 58 | { 59 | "package": "LegibleError", 60 | "repositoryURL": "https://github.com/mxcl/LegibleError.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "c615c01e461e8a3495ba4ea75f5d671c76820105", 64 | "version": "1.0.1" 65 | } 66 | }, 67 | { 68 | "package": "Multipart", 69 | "repositoryURL": "https://github.com/vapor/multipart.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "fb216c5a8ef07dcd90aec8a4155e86c831acce97", 73 | "version": "3.1.3" 74 | } 75 | }, 76 | { 77 | "package": "Redis", 78 | "repositoryURL": "https://github.com/vapor/redis.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "b6c0c98d2219f550ccf23c4bb8f8b14d75356bc6", 82 | "version": "3.4.0" 83 | } 84 | }, 85 | { 86 | "package": "Routing", 87 | "repositoryURL": "https://github.com/vapor/routing.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "d76f339c9716785e5079af9d7075d28ff7da3d92", 91 | "version": "3.1.0" 92 | } 93 | }, 94 | { 95 | "package": "Service", 96 | "repositoryURL": "https://github.com/vapor/service.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "fa5b5de62bd68bcde9a69933f31319e46c7275fb", 100 | "version": "1.0.2" 101 | } 102 | }, 103 | { 104 | "package": "swift-nio", 105 | "repositoryURL": "https://github.com/apple/swift-nio.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "546610d52b19be3e19935e0880bb06b9c03f5cef", 109 | "version": "1.14.4" 110 | } 111 | }, 112 | { 113 | "package": "swift-nio-ssl", 114 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "0f3999f3e3c359cc74480c292644c3419e44a12f", 118 | "version": "1.4.0" 119 | } 120 | }, 121 | { 122 | "package": "swift-nio-ssl-support", 123 | "repositoryURL": "https://github.com/apple/swift-nio-ssl-support.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "c02eec4e0e6d351cd092938cf44195a8e669f555", 127 | "version": "1.0.0" 128 | } 129 | }, 130 | { 131 | "package": "swift-nio-zlib-support", 132 | "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", 136 | "version": "1.0.0" 137 | } 138 | }, 139 | { 140 | "package": "TemplateKit", 141 | "repositoryURL": "https://github.com/vapor/template-kit.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "4370aa99c01fc19cc8272b67bf7204b2d2063680", 145 | "version": "1.5.0" 146 | } 147 | }, 148 | { 149 | "package": "URLEncodedForm", 150 | "repositoryURL": "https://github.com/vapor/url-encoded-form.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "20f68fbe7fac006d4d0617ea4edcba033227359e", 154 | "version": "1.1.0" 155 | } 156 | }, 157 | { 158 | "package": "Validation", 159 | "repositoryURL": "https://github.com/vapor/validation.git", 160 | "state": { 161 | "branch": null, 162 | "revision": "4de213cf319b694e4ce19e5339592601d4dd3ff6", 163 | "version": "2.1.1" 164 | } 165 | }, 166 | { 167 | "package": "Vapor", 168 | "repositoryURL": "https://github.com/vapor/vapor.git", 169 | "state": { 170 | "branch": null, 171 | "revision": "642f3d4d1f0eafad651c85524d0d1c698b55399f", 172 | "version": "3.3.3" 173 | } 174 | }, 175 | { 176 | "package": "WebSocket", 177 | "repositoryURL": "https://github.com/vapor/websocket.git", 178 | "state": { 179 | "branch": null, 180 | "revision": "d85e5b6dce4d04065865f77385fc3324f98178f6", 181 | "version": "1.1.2" 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: "Camille", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | products: [ 10 | .executable(name: "Camille", targets: ["Camille"]), 11 | .library(name: "CamilleServices", targets: ["CamilleServices"]), 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: "Camille", dependencies: [ 19 | "CamilleServices", 20 | .product(name: "VaporProviders", package: "Chameleon"), 21 | .product(name: "LegibleError", package: "LegibleError"), 22 | ]), 23 | .target(name: "CamilleServices", dependencies: [.product(name: "ChameleonKit", package: "Chameleon")]), 24 | .testTarget(name: "CamilleTests", dependencies: [ 25 | "CamilleServices", 26 | .product(name: "ChameleonTestKit", package: "Chameleon") 27 | ]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: Camille serve --hostname 0.0.0.0 --port $PORT -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Camille 6 | Camille is the Slack bot for iOS Developers HQ, built with Swift using [Chameleon](https://github.com/ChameleonBot/Bot). 7 | 8 | ![Swift](https://camo.githubusercontent.com/0727f3687a1e263cac101c5387df41048641339c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53776966742d332e302d6f72616e67652e7376673f7374796c653d666c6174) 9 | ![Platforms](https://img.shields.io/badge/Platforms-osx%20%7C%20linux-lightgrey.svg) 10 | 11 | ## Contributing 12 | Want to add a new feature to Camille? found a bug?
13 | Head over and read our [Contribution Guide](https://github.com/iosdevelopershq/Camille/blob/master/.github/CONTRIBUTING.md) to get started!
14 | Once you are setup have a look at Chameleon's [API Guide](https://github.com/ChameleonBot/Bot/blob/master/API.md) 15 | 16 | ## Contact 17 | [iOS Developers](http://ios-developers.io) 18 | -------------------------------------------------------------------------------- /Sources/Camille/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CamilleServices 3 | import ChameleonKit 4 | import VaporProviders 5 | import LegibleError 6 | 7 | let env = Environment() 8 | 9 | let keyValueStore: KeyValueStorage 10 | let storage: Storage 11 | 12 | #if !os(Linux) 13 | keyValueStore = MemoryKeyValueStorage() 14 | storage = PListStorage(name: "camille") 15 | #else 16 | let storageUrl = URL(string: try env.get(forKey: "STORAGE_URL"))! 17 | keyValueStore = RedisKeyValueStorage(url: storageUrl) 18 | storage = RedisStorage(url: storageUrl) 19 | #endif 20 | 21 | let bot = try SlackBot 22 | .vaporBased( 23 | verificationToken: try env.get(forKey: "VERIFICATION_TOKEN"), 24 | accessToken: try env.get(forKey: "ACCESS_TOKEN") 25 | ) 26 | .enableHello() 27 | .enableKarma(config: .default(), storage: storage) 28 | .enableEarlyWarning(config: .default()) 29 | 30 | bot.listen(for: .error) { bot, error in 31 | let channel = Identifier(rawValue: "#camille_errors") 32 | try bot.perform(.speak(in: channel, "\("Error: ", .bold)")) 33 | try bot.perform(.upload(channels: [channel], filetype: .javascript, Data(error.legibleLocalizedDescription.utf8))) 34 | } 35 | 36 | 37 | let errorChannel = Identifier(rawValue: "G015J4U0SUC") 38 | 39 | extension SlackEvent { 40 | static var all: SlackEvent<[String: Any]> { 41 | return .init( 42 | identifier: "any", 43 | canHandle: { type, json in 44 | switch type { 45 | case "message": 46 | return (json["channel"] as? String) != errorChannel.rawValue 47 | default: 48 | return true 49 | } 50 | }, 51 | handler: { $0 } 52 | ) 53 | } 54 | } 55 | 56 | var dumping = false 57 | bot.listen(for: .message) { bot, message in 58 | guard message.channel == errorChannel else { return } 59 | 60 | try message.matching(^.user(bot.me) && " start") { dumping = true } 61 | try message.matching(^.user(bot.me) && " stop") { dumping = false } 62 | } 63 | 64 | bot.listen(for: .all) { (bot, json) in 65 | guard dumping else { return } 66 | let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) 67 | let string = String(data: data, encoding: .utf8) ?? "" 68 | 69 | guard !string.isEmpty else { return } 70 | 71 | try bot.perform(.speak(in: errorChannel, "\(string)")) 72 | } 73 | 74 | try bot.start() 75 | -------------------------------------------------------------------------------- /Sources/CamilleServices/Configs/Configs+CrossPostService.swift: -------------------------------------------------------------------------------- 1 | //import Sugar 2 | // 3 | //extension Configs { 4 | // static let CrossPost = CrossPostServiceConfig( 5 | // timeSpan: 60 * 2, 6 | // includeMessage: { message in 7 | // return message.text.components(separatedBy: " ").count > 5 8 | // }, 9 | // reportingTarget: "admins", 10 | // publicWarning: { channel, user in 11 | // return try SlackMessage() 12 | // .line(user, " cross posting is discouraged.") 13 | // .makeChatPostMessage(target: channel) 14 | // }, 15 | // privateWarning: { im in 16 | // return try SlackMessage() 17 | // .line("Please refrain from cross posting, it is discouraged here.") 18 | // .makeChatPostMessage(target: im) 19 | // } 20 | // ) 21 | //} 22 | -------------------------------------------------------------------------------- /Sources/CamilleServices/Configs/Configs+KarmaService.swift: -------------------------------------------------------------------------------- 1 | //import Sugar 2 | // 3 | //extension Configs { 4 | // static let Karma = KarmaService.Config( 5 | // topUsersLimit: 20, 6 | // karmaAdjusters: [("++", 1), ("--", -1)], 7 | // textDistanceThreshold: 4, 8 | // allowedBufferCharacters: [" ", ":"], 9 | // positiveMessage: { user, total in 10 | // return ["\(user.name) you rock!: \(total)"] 11 | // }, 12 | // negativeMessage: { user, total in 13 | // return ["Boooo \(user.name)!: \(total)"] 14 | // } 15 | // ) 16 | //} 17 | -------------------------------------------------------------------------------- /Sources/CamilleServices/Configs/Configs+TopicService.swift: -------------------------------------------------------------------------------- 1 | //import Sugar 2 | // 3 | //extension Configs { 4 | // static let Topic = TopicServiceConfig( 5 | // userAllowed: { user in 6 | // return user.is_admin 7 | // }, 8 | // warning: { channel, user in 9 | // return try SlackMessage() 10 | // .line("I can't let you do that, ", user, ". Only admins are allowed to change the topic.") 11 | // .makeChatPostMessage(target: channel) 12 | // } 13 | // ) 14 | //} 15 | -------------------------------------------------------------------------------- /Sources/CamilleServices/Configs/Configs+UserJoinService.swift: -------------------------------------------------------------------------------- 1 | //import Sugar 2 | // 3 | //extension Configs { 4 | // static let UserJoin = UserJoinConfig(newUserAnnouncement: { im in 5 | // return try SlackMessage() 6 | // .line("Hi, ", im.user, ", welcome to the ios-developer slack team!") 7 | // .makeChatPostMessage(target: im) 8 | // }) 9 | //} 10 | -------------------------------------------------------------------------------- /Sources/CamilleServices/Configs/Configs.swift: -------------------------------------------------------------------------------- 1 | // 2 | //enum Configs { } 3 | -------------------------------------------------------------------------------- /Sources/CamilleServices/CrossPost/CrossPostButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | //enum CrossPostButton: String { 3 | // case privateWarning 4 | // case publicWarning 5 | // case removeAll 6 | // 7 | // var text: String { 8 | // switch self { 9 | // case .privateWarning: return "Private Warning" 10 | // case .publicWarning: return "Public Warning" 11 | // case .removeAll: return "Remove all posts" 12 | // } 13 | // } 14 | // 15 | // var afterExecuted: [CrossPostButton] { 16 | // switch self { 17 | // case .privateWarning: return [.removeAll] 18 | // case .publicWarning: return [.removeAll] 19 | // case .removeAll: return [.privateWarning, .publicWarning] 20 | // } 21 | // } 22 | // 23 | // static var all: [CrossPostButton] { return [.privateWarning, .publicWarning, .removeAll] } 24 | //} 25 | -------------------------------------------------------------------------------- /Sources/CamilleServices/CrossPost/CrossPostService.swift: -------------------------------------------------------------------------------- 1 | //import Bot 2 | //import Sugar 3 | //import Foundation 4 | // 5 | ////MARK: - Service 6 | //final class CrossPostService: SlackMessageService, SlackInteractiveButtonResponderService { 7 | // //MARK: - Properties 8 | // let buttonResponder = SlackInteractiveButtonResponder() 9 | // fileprivate let config: CrossPostServiceConfig 10 | // private var timer: TimerService? 11 | // private var messages: [MessageDecorator] = [] 12 | // 13 | // //MARK: - Lifecycle 14 | // init(config: CrossPostServiceConfig) { 15 | // self.config = config 16 | // } 17 | // 18 | // //MARK: - Event Routing 19 | // func configureEvents(slackBot: SlackBot, webApi: WebAPI, dispatcher: SlackRTMEventDispatcher) { 20 | // self.configureMessageEvent(slackBot: slackBot, webApi: webApi, dispatcher: dispatcher) 21 | // self.timer = TimerService(id: "crossPosting", interval: self.config.timeSpan, storage: slackBot.storage, dispatcher: dispatcher) { [weak self] pong in 22 | // guard let `self` = self else { return } 23 | // 24 | // //get rid of messages older than config.timeSpan 25 | // let activeMessages = self.messages.filter(self.newerThan(timestamp: pong.timestamp, lifespan: self.config.timeSpan)) 26 | // self.messages = activeMessages 27 | // 28 | // guard let message = self.makeCrossPostWarning(pong: pong, messages: self.messages, webApi: webApi) else { return } 29 | // 30 | // self.messages = [] 31 | // 32 | // guard 33 | // let target = slackBot.target(nameOrId: self.config.reportingTarget) 34 | // else { return } 35 | // 36 | // try webApi.execute(message.makeChatPostMessage(target: target)) 37 | // } 38 | // } 39 | // func messageEvent(slackBot: SlackBot, webApi: WebAPI, message: MessageDecorator, previous: MessageDecorator?) throws { 40 | // //only add new _channel_ messages and ones that meet the requirements 41 | // guard 42 | // message.target?.channel != nil, 43 | // previous == nil, 44 | // self.config.includeMessage(message) 45 | // else { return } 46 | // 47 | // self.messages.append(message) 48 | // } 49 | //} 50 | // 51 | ////MARK: - CrossPost Check 52 | //fileprivate extension CrossPostService { 53 | // func makeCrossPostWarning(pong: Pong, messages: [MessageDecorator], webApi: WebAPI) -> SlackMessage? { 54 | // let duplicates = self.findCrossPosts(in: messages) 55 | // guard !duplicates.isEmpty else { return nil } 56 | // 57 | // let message = SlackMessage() 58 | // .line("Cross Post Alert:".bold) 59 | // .line("The following cross posts have been detected:") 60 | // .attachments(for: duplicates) { builder, dupes in 61 | // dupes.addAttachment( 62 | // with: builder, 63 | // buttonResponder: self, 64 | // handler: self.buttonHandler(messages: dupes, webApi: webApi) 65 | // ) 66 | // } 67 | // 68 | // return message 69 | // } 70 | //} 71 | // 72 | ////MARK: - CrossPost Data 73 | //fileprivate extension CrossPostService { 74 | // func uniqueMessageKey(message: MessageDecorator) -> String { 75 | // let userId = message.sender?.id ?? "" 76 | // return "\(userId)\(message.text.hashValue)" 77 | // } 78 | // func potentialDuplicates(messages: [MessageDecorator]) -> Bool { 79 | // guard 80 | // messages.count > 1, 81 | // let first = messages.first, 82 | // first.sender != nil 83 | // else { return false } 84 | // 85 | // return !first.text.isEmpty 86 | // } 87 | // func postedInMultipleChannels(messages: [MessageDecorator]) -> Bool { 88 | // return messages 89 | // .grouped { $0.target?.name ?? "" } 90 | // .values.count > 1 91 | // } 92 | // func findCrossPosts(in messages: [MessageDecorator]) -> [[MessageDecorator]] { 93 | // let potentialDuplicates = messages 94 | // .grouped(by: self.uniqueMessageKey) 95 | // .values 96 | // .filter(self.potentialDuplicates) 97 | // .filter(self.postedInMultipleChannels) 98 | // 99 | // return Array(potentialDuplicates) 100 | // } 101 | //} 102 | // 103 | ////MARK: - Cross Post Button Responder 104 | //fileprivate extension CrossPostService { 105 | // func buttonHandler(messages: [MessageDecorator], webApi: WebAPI) -> (InteractiveButtonResponse) throws -> Void { 106 | // return { response in 107 | // guard 108 | // let name = response.actions.first?.name, 109 | // let action = CrossPostButton(rawValue: name) 110 | // else { return } 111 | // 112 | // switch action { 113 | // case .publicWarning: try self.publicWarning(messages: messages, webApi: webApi) 114 | // case .privateWarning: try self.privateWarning(messages: messages, webApi: webApi) 115 | // case .removeAll: try self.removeAll(messages: messages, webApi: webApi) 116 | // } 117 | // 118 | // try self.updateWarning( 119 | // message: response.original_message, 120 | // after: action, 121 | // from: response, 122 | // webApi: webApi 123 | // ) 124 | // } 125 | // } 126 | // 127 | // private func publicWarning(messages: [MessageDecorator], webApi: WebAPI) throws { 128 | // guard 129 | // let user = messages.first?.sender, 130 | // let target = messages.first?.target 131 | // else { return } 132 | // 133 | // let message = try self.config.publicWarning(target, user) 134 | // try webApi.execute(message) 135 | // } 136 | // private func privateWarning(messages: [MessageDecorator], webApi: WebAPI) throws { 137 | // guard let user = messages.first?.sender else { return } 138 | // 139 | // let openIm = IMOpen(user: user) 140 | // let im = try webApi.execute(openIm) 141 | // 142 | // let message = try self.config.privateWarning(im) 143 | // try webApi.execute(message) 144 | // } 145 | // private func removeAll(messages: [MessageDecorator], webApi: WebAPI) throws { 146 | // for message in messages { 147 | // let delete = ChatDelete(message: message.message) 148 | // try webApi.execute(delete) 149 | // } 150 | // } 151 | //} 152 | // 153 | ////MARK: - Helpers 154 | //fileprivate extension CrossPostService { 155 | // func newerThan(timestamp: Int, lifespan: TimeInterval) -> (MessageDecorator) -> Bool { 156 | // return { message in 157 | // guard let messageTimestamp = Int(message.message.timestamp) else { return true } 158 | // return (timestamp - messageTimestamp) < Int(lifespan) 159 | // } 160 | // } 161 | //} 162 | // 163 | //fileprivate extension CrossPostService { 164 | // func updateWarning(message: Message, after action: CrossPostButton, from interactiveButton: InteractiveButtonResponse, webApi: WebAPI) throws { 165 | // 166 | // let update = SlackMessage(message: message) 167 | // .updateAttachment(buttonResponse: interactiveButton) { builder in 168 | // builder.updateOrAddField(titled: "Actions Taken") { field in 169 | // var values = field.value.components(separatedBy: "\n") 170 | // values.append("<@\(interactiveButton.user.id)> chose: \(action.text)") 171 | // field.value = values.joined(separator: "\n") 172 | // } 173 | // builder.removeButtons(matching: { !action.afterExecuted.map({ $0.rawValue }).contains($0.name) }) 174 | // } 175 | // 176 | // try webApi.execute(try update.makeChatUpdate(to: message, in: interactiveButton.channel)) 177 | // } 178 | //} 179 | // 180 | -------------------------------------------------------------------------------- /Sources/CamilleServices/CrossPost/CrossPostServiceConfig.swift: -------------------------------------------------------------------------------- 1 | //import Bot 2 | //import Sugar 3 | //import Foundation 4 | // 5 | //struct CrossPostServiceConfig { 6 | // let timeSpan: TimeInterval 7 | // let includeMessage: (MessageDecorator) -> Bool 8 | // let reportingTarget: String 9 | // let publicWarning: (SlackTargetType, User) throws -> ChatPostMessage 10 | // let privateWarning: (IM) throws -> ChatPostMessage 11 | //} 12 | -------------------------------------------------------------------------------- /Sources/CamilleServices/CrossPost/Sequence+MessageDecorator.swift: -------------------------------------------------------------------------------- 1 | //import Sugar 2 | // 3 | //extension Collection where Iterator.Element == MessageDecorator { 4 | // func addAttachment(with builder: SlackMessageAttachmentBuilder, buttonResponder: SlackInteractiveButtonResponderService, handler: @escaping InteractiveButtonResponseHandler) { 5 | // guard let message = self.first, let user = message.sender else { return } 6 | // let channels = self 7 | // .flatMap { $0.target?.channel } 8 | // .map { "<#\($0.id)>" } 9 | // .joined(separator: ", ") 10 | // 11 | // builder.field(short: true, title: "User", value: "<@\(user.id)>") 12 | // builder.field(short: true, title: "Channels", value: channels) 13 | // builder.field(short: false, title: "Message Preview", value: message.text.substring(to: 50)) 14 | // for button in CrossPostButton.all { 15 | // builder.button( 16 | // name: button.rawValue, 17 | // text: button.text, 18 | // responder: buttonResponder, 19 | // handler: handler 20 | // ) 21 | // } 22 | // } 23 | //} 24 | -------------------------------------------------------------------------------- /Sources/CamilleServices/EarlyWarning/EarlyWarning+Config.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ChameleonKit 3 | 4 | extension SlackBot.EarlyWarning { 5 | public struct Config { 6 | /// Channel to report new users whose email domain that match one of the provided domains 7 | /// Provide `nil` to not report on this 8 | public var alertChannel: Identifier? 9 | 10 | /// Channel to report new users whose email domain _doesn't_ match one of the provided domains 11 | /// Provide `nil` to not report on this 12 | public var emailChannel: Identifier? 13 | 14 | /// Domains to check new users emails against 15 | public var domains: Set 16 | 17 | public init(alertChannel: String?, emailChannel: String?, domains: Set) { 18 | self.alertChannel = alertChannel.map(Identifier.init(rawValue:)) 19 | self.emailChannel = emailChannel.map(Identifier.init(rawValue:)) 20 | self.domains = domains 21 | } 22 | 23 | public static func `default`() throws -> Config { 24 | let blocklistUrl = URL(string: "https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blocklist.conf")! 25 | let blocklist = try Array(import: blocklistUrl, delimiters: .newlines) 26 | 27 | let blocklistUrl2 = URL(string: "https://raw.githubusercontent.com/andreis/disposable-email-domains/master/domains.txt")! 28 | let blocklist2 = try Array(import: blocklistUrl2, delimiters: .newlines) 29 | 30 | 31 | let domains = [ 32 | "apkmd.com", 33 | "autism.exposed", 34 | "car101.pro", 35 | "deaglenation.tv", 36 | "gamergate.us", 37 | "housat.com", 38 | "muslims.exposed", 39 | "nutpa.net", 40 | "p33.org", 41 | "vps30.com", 42 | "awdrt.com", 43 | "awdrt.net", 44 | "ttirv.com", 45 | "ttirv.net", 46 | "kewrg.com", 47 | "royandk.com", 48 | "opwebw.com", 49 | "yevme.com", 50 | "ktumail.com", 51 | "tastrg.com", 52 | "andsee.org", 53 | "gomail5.com", 54 | "mailerv.net", 55 | "eoopy.com", 56 | "mail2paste.com", 57 | "vmani.com", 58 | "miucce.online", 59 | "avxrja.com" 60 | ] 61 | 62 | return .init(alertChannel: "admins", emailChannel: "new-users", domains: Set(blocklist + blocklist2 + domains)) 63 | } 64 | } 65 | } 66 | 67 | private extension Array where Element == String { 68 | init(import: URL, delimiters: CharacterSet) throws { 69 | let data = try Data(contentsOf: `import`) 70 | if let string = String(data: data, encoding: .utf8) { 71 | self = string.components(separatedBy: delimiters) 72 | 73 | } else { 74 | self = [] 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/CamilleServices/EarlyWarning/EarlyWarning.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | 3 | extension SlackBot { 4 | public enum EarlyWarning { } 5 | } 6 | 7 | extension SlackBot { 8 | public func enableEarlyWarning(config: EarlyWarning.Config) -> SlackBot { 9 | listen(for: .teamJoin) { bot, newUser in 10 | let user = try bot.lookup(newUser.id) 11 | 12 | guard let email = user.profile.email, let domain = email.components(separatedBy: "@").last else { return } 13 | 14 | var destination: Identifier? 15 | 16 | if config.domains.contains(domain) { destination = config.alertChannel } 17 | else { destination = config.emailChannel } 18 | 19 | guard let channel = destination else { return } 20 | 21 | let ip = try? bot.perform(.teamAccessLogs(count: 20)).logins.first(where: { $0.user_id == user.id })?.ip 22 | let ipString = ip.map { " (IP: \($0))" } ?? "" 23 | 24 | try bot.perform(.speak(in: channel, "New user \(user) has joined with the email \(email)\(ipString)")) 25 | } 26 | 27 | return self 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/CamilleServices/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/CamilleServices/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 | let plusOne = ElementMatcher(^"++").map(KarmaModifier { $0 + 1 }) 10 | let plusN = ElementMatcher("+=" && optional(.whitespace) *> .integer).map { n in KarmaModifier { $0 + n } } 11 | 12 | let minusOne = ElementMatcher(^"--").map(KarmaModifier { $0 - 1 }) 13 | let minusN = ElementMatcher("-=" && optional(.whitespace) *> .integer).map { n in KarmaModifier { $0 - n } } 14 | 15 | return plusOne || plusN || minusOne || minusN 16 | } 17 | } 18 | 19 | extension SlackBot.Karma { 20 | static func tryAdjustments(_ config: Config, _ storage: Storage, _ bot: SlackBot, _ message: Message) throws { 21 | typealias KarmaMatch = (Identifier, KarmaModifier) 22 | 23 | try message.richText().matchingAll([.user, .karma]) { (updates: [KarmaMatch]) in 24 | // consolidate any updates for the same user 25 | var tally: [Identifier: Int] = [:] 26 | 27 | for update in updates { 28 | let current = tally[update.0, default: 0] 29 | tally[update.0] = update.1.update(current) 30 | } 31 | 32 | // filter out unwanted results 33 | tally[message.user] = 0 // remove any 'self-karma' 34 | let validUpdates = tally.filter({ $0.value != 0 }).keys 35 | 36 | guard !validUpdates.isEmpty else { return } 37 | 38 | // perform updates and build response 39 | var responses: [MarkdownString] = [] 40 | for user in validUpdates { 41 | let newTotal: Int 42 | let birthday: Bool 43 | 44 | do { 45 | let currentTotal: Int = try storage.get(forKey: user.rawValue, from: Keys.namespace) 46 | newTotal = currentTotal + tally[user]! 47 | birthday = false 48 | 49 | } catch StorageError.missing { 50 | newTotal = tally[user]! 51 | birthday = true 52 | } 53 | 54 | try storage.set(forKey: user.rawValue, from: Keys.namespace, value: newTotal) 55 | 56 | let commentFormatter = (tally[user]! > 0 57 | ? config.positiveComments.randomElement().map(withBirthday(birthday)) 58 | : config.negativeComments.randomElement().map(withBirthday(birthday)) 59 | ) ?? { "\($0): \($1)" } 60 | 61 | responses.append(commentFormatter(user, newTotal)) 62 | } 63 | 64 | try bot.perform(.respond(to: message, .inline, with: responses.joined(separator: "\n"))) 65 | } 66 | } 67 | } 68 | 69 | private func withBirthday(_ birthday: Bool) -> (@escaping SlackBot.Karma.CommentsFormatter) -> SlackBot.Karma.CommentsFormatter { 70 | return { original in 71 | let birthdayPrefix: MarkdownString = "\(.balloon) " 72 | 73 | return birthday 74 | ? { birthdayPrefix.appending(original($0, $1)) } 75 | : original 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/CamilleServices/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 | return Config( 19 | topUserLimit: 10, 20 | positiveComments: [ 21 | { "You rock \($0)! Now at \($1)." }, 22 | { "Nice job, \($0)! Your karma just bumped to \($1)." }, 23 | { "Awesome \($0)! You’re now at \($1) \(pluralizedScoreString(from: $1))." } 24 | ], 25 | negativeComments: [ 26 | { "booooo \($0)! Now at \($1)." }, 27 | { "Tssss \($0). Dropped your karma to \($1)." }, 28 | { "Sorry, but I have to drop \($0)’s karma down to \($1) \(pluralizedScoreString(from: $1))." }, 29 | ] 30 | ) 31 | } 32 | } 33 | } 34 | 35 | private extension SlackBot.Karma.Config { 36 | /// Non-localized pluralization. 37 | /// 38 | /// - Parameter score: a point score. 39 | /// - Returns: `"point"` if score is 1; otherwise, `"points"`. 40 | /// - Note: Should be replaced by proper localized pluralization 41 | static func pluralizedScoreString(from score: Int) -> String { 42 | return score == 1 ? "point" : "points" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CamilleServices/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/CamilleServices/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/CamilleServices/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/CamilleServices/Swift/SwiftService.swift: -------------------------------------------------------------------------------- 1 | //import Foundation 2 | //import Chameleon 3 | // 4 | //struct CodeMatcher: Matcher { 5 | // func match(against input: String) -> Match? { 6 | // guard input.hasPrefix("`") && input.hasSuffix("`") else { return nil } 7 | // 8 | // let code = input.trim(characters: ["`"]).stringByDecodingHTMLEntities 9 | // 10 | // guard !code.isEmpty else { return nil } 11 | // 12 | // return Match(key: nil, value: code, matched: input) 13 | // } 14 | // var matcherDescription: String { 15 | // return "(code)" 16 | // } 17 | //} 18 | // 19 | //public class SwiftService: SlackBotMessageService, HelpRepresentable { 20 | // private let network: Network 21 | // private let token: String 22 | // 23 | // public let topic = "Swift Code" 24 | // public let description = "Execute some Swift code and see the result" 25 | // public let pattern: [Matcher] = [["execute", "run"].any, "\n".orNone, CodeMatcher().using(key: Keys.code)] 26 | // 27 | // enum Keys { 28 | // static let code = "code" 29 | // } 30 | // 31 | // public init(network: Network, token: String) { 32 | // self.network = network 33 | // self.token = token 34 | // } 35 | // 36 | // public func configure(slackBot: SlackBot) { 37 | // slackBot.registerHelp(item: self) 38 | // 39 | // configureMessageService(slackBot: slackBot) 40 | // } 41 | // public func onMessage(slackBot: SlackBot, message: MessageDecorator, previous: MessageDecorator?) throws { 42 | // try slackBot.route(message, matching: self) { bot, msg, match in 43 | // let result = try request(code: match.value(key: Keys.code)) 44 | // 45 | // let response = try msg.respond() 46 | // response.text(["Output:"]).newLine() 47 | // 48 | // if !result.stderr.isEmpty { 49 | // response.text([result.stderr.pre]) 50 | // 51 | // } else { 52 | // response.text([result.stdout.quote]) 53 | // } 54 | // 55 | // try bot.send(response.makeChatMessage()) 56 | // } 57 | // } 58 | // 59 | // private func request(code: String) throws -> GlotResponse { 60 | // let body: [String: Any] = [ 61 | // "files": [ 62 | // [ 63 | // "name": "main.swift", 64 | // "content": code, 65 | // ] 66 | // ] 67 | // ] 68 | // 69 | // let request = NetworkRequest( 70 | // method: .POST, 71 | // url: "https://run.glot.io/languages/swift/latest", 72 | // headers: [ 73 | // "Authorization": "Token \(token)", 74 | // "Content-type": "application/json", 75 | // ], 76 | // body: body.makeData() 77 | // ) 78 | // 79 | // let response = try network.perform(request: request) 80 | // 81 | // guard let json = response.jsonDictionary 82 | // else { throw NetworkError.invalidResponse(response) } 83 | // 84 | // let decoder = Decoder(data: json) 85 | // return try GlotResponse(decoder: decoder) 86 | // } 87 | //} 88 | // 89 | //private struct GlotResponse { 90 | // let stdout: String 91 | // let stderr: String 92 | // let error: String 93 | //} 94 | // 95 | //extension GlotResponse: Common.Decodable { 96 | // init(decoder: Common.Decoder) throws { 97 | // self = try decode { 98 | // return GlotResponse( 99 | // stdout: try decoder.value(at: ["stdout"]), 100 | // stderr: try decoder.value(at: ["stderr"]), 101 | // error: try decoder.value(at: ["error"]) 102 | // ) 103 | // } 104 | // } 105 | //} 106 | -------------------------------------------------------------------------------- /Sources/CamilleServices/TopicService.swift: -------------------------------------------------------------------------------- 1 | //import Bot 2 | //import Sugar 3 | // 4 | //typealias UserAllowedClosure = (User) -> Bool 5 | //typealias TopicWarningClosure = (Channel, User) throws -> ChatPostMessage 6 | // 7 | //struct TopicServiceConfig { 8 | // let userAllowed: UserAllowedClosure 9 | // let warning: TopicWarningClosure? 10 | //} 11 | // 12 | //final class TopicService: SlackRTMEventService, SlackConnectionService { 13 | // //MARK: - Properties 14 | // fileprivate let config: TopicServiceConfig 15 | // 16 | // //MARK: - Lifecycle 17 | // init(config: TopicServiceConfig) { 18 | // self.config = config 19 | // } 20 | // 21 | // //MARK: - Connection Event 22 | // func connected(slackBot: SlackBot, botUser: BotUser, team: Team, users: [User], channels: [Channel], groups: [Group], ims: [IM]) throws { 23 | // try self.updateTopics(for: channels, in: slackBot.storage) 24 | // } 25 | // 26 | // //MARK: - RTMAPI Events 27 | // func configureEvents(slackBot: SlackBot, webApi: WebAPI, dispatcher: SlackRTMEventDispatcher) { 28 | // dispatcher.onEvent(message.self) { data in 29 | // try self.messageEvent(slackBot: slackBot, webApi: webApi, message: data.message) 30 | // } 31 | // dispatcher.onEvent(channel_joined.self) { channel in 32 | // try self.updateTopics(for: [channel], in: slackBot.storage) 33 | // } 34 | // } 35 | //} 36 | // 37 | ////MARK: - Topic Sync 38 | //fileprivate extension TopicService { 39 | // func updateTopics(for channels: [Channel], in storage: Storage) throws { 40 | // for channel in channels { 41 | // try storage.set(.in("topics"), key: channel.id, value: channel.topic?.value) 42 | // } 43 | // } 44 | //} 45 | // 46 | ////MARK: - Topic Updates 47 | //fileprivate extension TopicService { 48 | // func messageEvent(slackBot: SlackBot, webApi: WebAPI, message: Message) throws { 49 | // guard 50 | // let subtype = message.subtype, 51 | // subtype == .channel_topic, 52 | // let topic = message.topic, 53 | // let user = message.user, 54 | // let channel = message.channel?.channel 55 | // else { return } 56 | // 57 | // if (self.config.userAllowed(user)) { 58 | // //update the stored topic 59 | // try slackBot.storage.set(.in("topics"), key: channel.id, value: topic) 60 | // 61 | // } else if let lastTopic: String = slackBot.storage.get(.in("topics"), key: channel.id) { 62 | // //reset the topic to the previous one set by an authorised user 63 | // let setTopic = ChannelSetTopic(channel: channel, topic: lastTopic) 64 | // _ = try webApi.execute(setTopic) 65 | // 66 | // //warn user if needed 67 | // guard let warning = self.config.warning else { return } 68 | // let message = try warning(channel, user) 69 | // _ = try webApi.execute(message) 70 | // } 71 | // } 72 | //} 73 | -------------------------------------------------------------------------------- /Sources/CamilleServices/UserJoinService.swift: -------------------------------------------------------------------------------- 1 | //import Bot 2 | //import Sugar 3 | // 4 | //struct UserJoinConfig { 5 | // let newUserAnnouncement: (IM) throws -> ChatPostMessage 6 | //} 7 | // 8 | //final class UserJoinService: SlackRTMEventService { 9 | // private let config: UserJoinConfig 10 | // 11 | // //MARK: - Lifecycle 12 | // init(config: UserJoinConfig) { 13 | // self.config = config 14 | // } 15 | // 16 | // //MARK: - Event Dispatch 17 | // func configureEvents(slackBot: SlackBot, webApi: WebAPI, dispatcher: SlackRTMEventDispatcher) { 18 | // dispatcher.onEvent(team_join.self) { user in 19 | // try self.teamJoinEvent(slackBot: slackBot, 20 | // webApi: webApi, 21 | // user: user) 22 | // } 23 | // } 24 | // func teamJoinEvent(slackBot: SlackBot, webApi: WebAPI, user: User) throws { 25 | // guard !user.is_bot else { return } 26 | // 27 | // let imOpenRequest = IMOpen(user: user) 28 | // let channel = try webApi.execute(imOpenRequest) 29 | // let message = try config.newUserAnnouncement(channel) 30 | // 31 | // try webApi.execute(message) 32 | // } 33 | //} 34 | -------------------------------------------------------------------------------- /Tests/CamilleTests/EarlyWarningTests.swift: -------------------------------------------------------------------------------- 1 | import CamilleServices 2 | import ChameleonKit 3 | import ChameleonTestKit 4 | import XCTest 5 | 6 | class EarlyWarningTests: XCTestCase { 7 | func testMatchingDomain() throws { 8 | let test = try SlackBot.test() 9 | let config = SlackBot.EarlyWarning.Config(alertChannel: "test", emailChannel: nil, domains: ["bad.com"]) 10 | _ = test.bot.enableEarlyWarning(config: config) 11 | 12 | try test.send(.event(.teamJoin(.user())), enqueue: [ 13 | .usersInfo(.user(email: "user@bad.com")), 14 | .emptyMessage() 15 | ]) 16 | 17 | XCTAssertClear(test) 18 | } 19 | func testNonMatchingDomain() throws { 20 | let test = try SlackBot.test() 21 | let config = SlackBot.EarlyWarning.Config(alertChannel: "test", emailChannel: nil, domains: ["bad.com"]) 22 | _ = test.bot.enableEarlyWarning(config: config) 23 | 24 | try test.send(.event(.teamJoin(.user())), enqueue: [ 25 | .usersInfo(.user(email: "user@good.com")) 26 | ]) 27 | 28 | XCTAssertClear(test) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/CamilleTests/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/CamilleTests/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/CamilleTests/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/CamilleTests/KarmaTests.swift: -------------------------------------------------------------------------------- 1 | import ChameleonKit 2 | import ChameleonTestKit 3 | @testable import CamilleServices 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(.event(.message([.text("Hey "), .user("1"), .text(" have you used C++?")]))) 88 | try test.send(.event(.message([.user("1"), .text("haha -- something something")]))) 89 | 90 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), []) 91 | XCTAssertClear(test) 92 | } 93 | 94 | func testKarma_EdgeCase_SelfKarma() throws { 95 | let test = try SlackBot.test() 96 | let storage = MemoryStorage() 97 | _ = test.bot.enableKarma(config: .default(), storage: storage) 98 | 99 | try test.send(.event(.message(userId: "1", [.user("1"), .text(" ++")]))) 100 | 101 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), []) 102 | XCTAssertClear(test) 103 | } 104 | 105 | func testKarma_EdgeCase_Punctuation() throws { 106 | let test = try SlackBot.test() 107 | let storage = MemoryStorage() 108 | _ = test.bot.enableKarma(config: .default(), storage: storage) 109 | 110 | try test.send(.event(.message([.text("(btw, "), .user("1"), .text("++)")])), enqueue: [.emptyMessage()]) 111 | 112 | try XCTAssertEqual(storage.keys(in: SlackBot.Karma.Keys.namespace), ["1"]) 113 | try XCTAssertEqual(storage.get(forKey: "1", from: SlackBot.Karma.Keys.namespace), 1) 114 | XCTAssertClear(test) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | // Not used 4 | -------------------------------------------------------------------------------- /camille.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevelopershq/camille/d8e609c98b9e8007dadca163246820d0ad4e2c0e/camille.jpg -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for Vapor 2 | # 3 | # Install Docker on your system to run and test 4 | # your Vapor app in a production-like environment. 5 | # 6 | # Note: This file is intended for testing and does not 7 | # implement best practices for a production deployment. 8 | # 9 | # Learn more: https://docs.docker.com/compose/reference/ 10 | # 11 | # Build images: docker-compose build 12 | # Start app: docker-compose up app 13 | # Stop all: docker-compose down 14 | # 15 | version: '3.7' 16 | 17 | x-shared_environment: &shared_environment 18 | LOG_LEVEL: ${LOG_LEVEL:-debug} 19 | 20 | services: 21 | app: 22 | image: camille-iosdevhq:latest 23 | platform: linux/amd64 24 | build: 25 | context: . 26 | environment: 27 | <<: *shared_environment 28 | ports: 29 | - '8080:8080' 30 | # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. 31 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 32 | --------------------------------------------------------------------------------