├── .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 | 
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 | 
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 | 
9 | 
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 |
--------------------------------------------------------------------------------