├── Tests ├── LinuxMain.swift └── AppTests │ └── AppTests.swift ├── .swift-version ├── .dockerignore ├── Procfile ├── logo.png ├── macstadium.png ├── .sourcery.yml ├── .gitignore ├── Sources ├── App │ ├── routes.swift │ ├── Types │ │ ├── Coords.swift │ │ ├── DecodableString.swift │ │ ├── Context.swift │ │ ├── BuildableType.swift │ │ ├── NodeAction.swift │ │ ├── Review.swift │ │ ├── Modeled │ │ │ ├── Makeuper.swift │ │ │ ├── Photographer.swift │ │ │ ├── Stylist.swift │ │ │ ├── Studio.swift │ │ │ ├── Promotion.swift │ │ │ ├── Order.swift │ │ │ ├── PlatformFile.swift │ │ │ └── Node.swift │ │ ├── SendMessage.swift │ │ ├── PromotionImpact.swift │ │ └── PromotionCondition.swift │ ├── Protocols │ │ ├── Model │ │ │ ├── SchemedModel.swift │ │ │ └── TypedModel.swift │ │ ├── Sourcery.swift │ │ ├── Buildable │ │ │ ├── Buildable.swift │ │ │ ├── HelperProtocols.swift │ │ │ ├── BuildableField.swift │ │ │ └── NodeBuildable.swift │ │ ├── Type │ │ │ └── ModeledType.swift │ │ ├── Priceable.swift │ │ ├── PlatformIdentifiable.swift │ │ └── Twinable.swift │ ├── Migrations │ │ ├── Siblings │ │ │ ├── CreateStudioPhotos.swift │ │ │ ├── CreateMakeuperPhotos.swift │ │ │ ├── CreateStylistPhotos.swift │ │ │ ├── CreateOrderPromotions.swift │ │ │ ├── CreatePhotographerPhotos.swift │ │ │ ├── CreateAgreements.swift │ │ │ ├── CreateSiblingPhotos.swift │ │ │ └── CreateSiblingPromotions.swift │ │ ├── CreateReviews.swift │ │ ├── CreatePlatformFiles.swift │ │ ├── CreatePhotographers.swift │ │ ├── CreateMakeupers.swift │ │ ├── CreateStylists.swift │ │ ├── Systemic │ │ │ └── CreateEventPayloads.swift │ │ ├── CreateStudios.swift │ │ ├── CreateNodes.swift │ │ ├── CreatePromotions.swift │ │ ├── CreateOrders.swift │ │ └── CreateUsers.swift │ ├── Extensions │ │ ├── Model+save.swift │ │ ├── Result+helpers.swift │ │ ├── Array+chunked.swift │ │ ├── Date+detectFromString.swift │ │ ├── Calendar+weekday.swift │ │ ├── BotterButton+init.swift │ │ ├── TimeInterval+components.swift │ │ ├── Button+init.swift │ │ ├── Replyable+replyNode.swift │ │ ├── Array+unique.swift │ │ ├── Array+shift.swift │ │ ├── Range+intervalDates.swift │ │ ├── String+extractUrls.swift │ │ ├── Decodable+init.swift │ │ ├── Future+throwingFlatMap.swift │ │ ├── Bot+sendNode.swift │ │ └── Validation.swift │ ├── Modules │ │ ├── Main │ │ │ ├── AboutNodeController.swift │ │ │ ├── PortfolioNodeController.swift │ │ │ ├── MainNodeController.swift │ │ │ ├── ReviewsNodeController.swift │ │ │ ├── UploadPhotoNodeController.swift │ │ │ └── OrdersNodeController.swift │ │ ├── Welcome │ │ │ ├── ShowcaseNodeController.swift │ │ │ └── WelcomeNodeController.swift │ │ ├── NodeController.swift │ │ ├── Order │ │ │ ├── OrderTypesNodeController.swift │ │ │ ├── Builder │ │ │ │ ├── OrderBuilderStudioNodeController.swift │ │ │ │ ├── OrderBuilderMakeuperNodeController.swift │ │ │ │ ├── OrderBuilderStylistNodeController.swift │ │ │ │ ├── OrderBuilderPhotographerNodeController.swift │ │ │ │ └── OrderBuilderMainNodeController.swift │ │ │ ├── OrderAgreementNodeController.swift │ │ │ ├── OrderCheckoutNodeController.swift │ │ │ └── OrderReplacementNodeController.swift │ │ └── Systemic │ │ │ └── ChangeTextNodeController.swift │ ├── Models │ │ ├── ReviewModel.swift │ │ ├── Siblings │ │ │ ├── StudioPhoto.swift │ │ │ ├── OrderPromotion.swift │ │ │ ├── MakeuperPhoto.swift │ │ │ ├── StylistPhoto.swift │ │ │ ├── PhotographerPhoto.swift │ │ │ └── AgreementModel.swift │ │ ├── Systemic │ │ │ └── EventPayloadModel.swift │ │ ├── PlatformFileModel.swift │ │ ├── PromotionModel.swift │ │ ├── MakeuperModel.swift │ │ ├── StylistModel.swift │ │ ├── PhotographerModel.swift │ │ ├── NodeModel.swift │ │ ├── StudioModel.swift │ │ ├── OrderModel.swift │ │ └── UserModel.swift │ ├── Twinable │ │ ├── ReviewProtocol.swift │ │ ├── PlatformFileProtocol.swift │ │ ├── Base │ │ │ ├── UsersProtocol.swift │ │ │ ├── PromotionsProtocol.swift │ │ │ └── PhotosProtocol.swift │ │ ├── StylistProtocol.swift │ │ ├── PromotionProtocol.swift │ │ ├── MakeuperProtocol.swift │ │ ├── PhotographerProtocol.swift │ │ ├── StudioProtocol.swift │ │ ├── NodeProtocol.swift │ │ ├── OrderProtocol.swift │ │ └── UserProtocol.swift │ ├── TgEchoBot.swift │ ├── Generated │ │ └── AutoCodable.generated.swift │ ├── VkEchoBot.swift │ └── EchoBot.swift └── Run │ └── main.swift ├── .github └── workflows │ ├── ubuntu.yml │ └── macos.yml ├── CONTRIBUTING.md ├── LICENSE ├── docker-compose.yml ├── Package.swift ├── README.md └── Dockerfile /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.3.1 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: Run serve --env production --hostname 0.0.0.0 --log debug -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CoolONEOfficial/PhotoBot/HEAD/logo.png -------------------------------------------------------------------------------- /macstadium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CoolONEOfficial/PhotoBot/HEAD/macstadium.png -------------------------------------------------------------------------------- /.sourcery.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | - Sources/App/ 3 | - Tests/ 4 | templates: 5 | - Templates/ 6 | output: 7 | Sources/App/Generated/ 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | DerivedData/ 6 | .DS_Store 7 | db.sqlite 8 | .swiftpm 9 | .env.* 10 | 11 | .env 12 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | import Telegrammer 4 | 5 | func routes(_ app: Application) throws { 6 | app.get { _ in 7 | "Photo bot working.." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/App/Types/Coords.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 23.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Coords: Codable { 11 | let lat: Double 12 | let long: Double 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Model/SchemedModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 23.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | //protocol Model: Model { 11 | // static var schema: String { get } 12 | //} 13 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Vapor 3 | import TelegrammerMiddleware 4 | 5 | var env = try Environment.detect() 6 | try LoggingSystem.bootstrap(from: &env) 7 | let app = Application(env) 8 | 9 | defer { app.shutdown() } 10 | try configure(app) 11 | try app.run() 12 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Sourcery.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 08.01.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol AutoDecodable: Decodable {} 11 | protocol AutoEncodable: Encodable {} 12 | protocol AutoCodable: AutoEncodable, AutoDecodable {} 13 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreateStudioPhotos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 23.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateStudioPhotos: CreateSiblingPhotos { 11 | typealias TwinType = Studio 12 | 13 | var name: String { "studio" } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreateMakeuperPhotos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateMakeuperPhotos: CreateSiblingPhotos { 11 | typealias TwinType = Makeuper 12 | 13 | var name: String { "makeuper" } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreateStylistPhotos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateStylistPhotos: CreateSiblingPhotos { 11 | typealias TwinType = Stylist 12 | 13 | var name: String { "stylist" } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreateOrderPromotions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CreateOrderPromotions: CreateSiblingPromotions { 11 | typealias TwinType = Order 12 | 13 | var name: String { "order" } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreatePhotographerPhotos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreatePhotographerPhotos: CreateSiblingPhotos { 11 | typealias TwinType = Photographer 12 | 13 | var name: String { "photographer" } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Model+save.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 13.01.2021. 6 | // 7 | 8 | import FluentKit 9 | import Botter 10 | 11 | extension Model { 12 | public func saveWithId(on database: Database) -> Future { 13 | save(on: database).flatMapThrowing { try self.requireID() } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ubuntu.yml: -------------------------------------------------------------------------------- 1 | name: Ubuntu 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Result+helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 16.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Result+Success 11 | 12 | extension Result where Success == Void { 13 | 14 | /// The success Result case 15 | static var success: Result { 16 | return .success(()) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/App/Types/DecodableString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.01.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias DecodableString = String 11 | 12 | extension DecodableString { 13 | func decode() throws -> T { 14 | try JSONDecoder.snakeCased.decode(T.self, from: data(using: .utf8)!) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Array+chunked.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | func chunked(into size: Int) -> [[Element]] { 12 | return stride(from: 0, to: count, by: size).map { 13 | Array(self[$0 ..< Swift.min($0 + size, count)]) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Date+detectFromString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 13.04.2021. 6 | // 7 | 8 | import SwiftyChrono 9 | import Foundation 10 | 11 | #if os(Linux) 12 | 13 | extension Date { 14 | init?(detectFromString text: String) { 15 | guard let date = Chrono().parseDate(text: text) else { return nil } 16 | self = date 17 | } 18 | } 19 | 20 | #endif 21 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Calendar+weekday.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Calendar { 11 | func weekday(date: Date) -> Int { 12 | var weekday = component(.weekday, from: date) - firstWeekday 13 | if weekday <= 0 { 14 | weekday += 7 15 | } 16 | return weekday - 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Extensions/BotterButton+init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 16.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vkontakter 11 | 12 | extension Botter.Button { 13 | init(text: String, action: NodeAction, color: Vkontakter.Button.Color? = nil, payload: String? = nil) throws { 14 | try self.init(text: text, action: .callback, color: color, data: action) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Extensions/TimeInterval+components.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension TimeInterval { 11 | var components: (hours: Int, minutes: Int, seconds: Int) { 12 | let (hr, minf) = modf (self / 3600) 13 | let (min, secf) = modf (60 * minf) 14 | return (hours: Int(hr), minutes: Int(min), seconds: Int(60 * secf)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import XCTVapor 3 | 4 | final class AppTests: XCTestCase { 5 | func testHelloWorld() throws { 6 | let app = Application(.testing) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | 10 | try app.test(.GET, "hello", afterResponse: { res in 11 | XCTAssertEqual(res.status, .ok) 12 | XCTAssertEqual(res.body.string, "Hello, world!") 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Button+init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 01.02.2021. 6 | // 7 | 8 | import Botter 9 | import Vkontakter 10 | import Vapor 11 | import Foundation 12 | 13 | extension Botter.Button { 14 | init(text: String, action: Action, color: Vkontakter.Button.Color? = nil, eventPayload: EventPayload?) throws { 15 | try self.init(text: text, action: action, color: color, data: eventPayload) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Model/TypedModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.01.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | 11 | //protocol TypedModel: Model { 12 | // associatedtype MyType: ModeledType & Cloneable 13 | // 14 | // func toMyType() throws -> MyType 15 | //} 16 | // 17 | //extension TypedModel where MyType.Model == Self { 18 | // func toMyType() throws -> MyType { try .init(from: self) } 19 | //} 20 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Replyable+replyNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 08.01.2021. 6 | // 7 | 8 | import Botter 9 | import Vapor 10 | 11 | extension Replyable where Self: PlatformObject { 12 | func replyNode(node: Node, payload: NodePayload?, context: PhotoBotContextProtocol) throws -> Future<[Message]>? { 13 | try context.bot.sendNode(to: self, node: node, payload: payload, platform: platform.any, context: context) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | Please feel free to contribute. Check out our issues list. 4 | 5 | ## Pull requests 6 | The best way to contribute is by submitting a pull request. 7 | I'll respond as soon as possible. 8 | 9 | ## Issues 10 | If you find a bug or you have a suggestion create an issue. 11 | 12 | ## Coding style 13 | 14 | Comment every public methods, properties, classes. 15 | Make commits as atomic as possible with undestandable comment. 16 | If you are developing feature of fixing bug, please mention issue number (e.g. #222) in commit text. 17 | -------------------------------------------------------------------------------- /Sources/App/Types/Context.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Botter 11 | 12 | public protocol PhotoBotContextProtocol: BotContextProtocol { 13 | var controllers: [NodeController] { get } 14 | var user: User { get set } 15 | } 16 | 17 | public struct PhotoBotContext: PhotoBotContextProtocol { 18 | public let app: Application 19 | public let bot: Botter.Bot 20 | public var user: User 21 | public let platform: AnyPlatform 22 | public let controllers: [NodeController] 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Modules/Main/AboutNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class AboutNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "About node", 16 | messagesGroup: [ 17 | .init(text: "Test message here."), 18 | .init(text: "And other message.") 19 | ], 20 | entryPoint: .about, app: app 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Types/BuildableType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 01.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum BuildableType: String, Codable { 11 | case node 12 | 13 | var type: Buildable.Type { 14 | switch self { 15 | case .node: 16 | return NodeBuildable.self 17 | } 18 | } 19 | } 20 | 21 | enum BuildableTypeError: Error { 22 | case unknownClass 23 | } 24 | 25 | extension BuildableType: Equatable { 26 | public static func == (lhs: BuildableType, rhs: BuildableType) -> Bool { 27 | lhs.type == rhs.type 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateReviews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 03.04.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | import Fluent 11 | 12 | struct CreateReviews: Migration { 13 | func prepare(on database: Database) -> EventLoopFuture { 14 | return database.schema(ReviewModel.schema) 15 | .id() 16 | .field("screenshot", .uuid, .required, .references(PlatformFileModel.schema, .id)) 17 | .create() 18 | } 19 | 20 | func revert(on database: Database) -> EventLoopFuture { 21 | return database.schema(ReviewModel.schema).delete() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Array+unique.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 03.06.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | func unique(by: ((Element) -> (T))) -> [Element] { 12 | var set = Set() //the unique list kept in a Set for fast retrieval 13 | var arrayOrdered = [Element]() //keeping the unique list of elements but ordered 14 | for value in self { 15 | if !set.contains(by(value)) { 16 | set.insert(by(value)) 17 | arrayOrdered.append(value) 18 | } 19 | } 20 | 21 | return arrayOrdered 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreatePlatformFiles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreatePlatformFiles: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | return database.schema(PlatformFileModel.schema) 13 | .id() 14 | .field("platform_entries", .array(of: .json), .required) 15 | .field("type", .string, .required) 16 | .create() 17 | } 18 | 19 | func revert(on database: Database) -> EventLoopFuture { 20 | return database.schema(PlatformFileModel.schema).delete() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreatePhotographers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreatePhotographers: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | return database.schema(PhotographerModel.schema) 13 | .id() 14 | .field("name", .string) 15 | .field("platform_ids", .array(of: .json)) 16 | .field("prices", .json, .required) 17 | .create() 18 | } 19 | 20 | func revert(on database: Database) -> EventLoopFuture { 21 | return database.schema(PhotographerModel.schema).delete() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateMakeupers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateMakeupers: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | return database.schema(MakeuperModel.schema) 13 | .id() 14 | .field("name", .string) 15 | .field("platform_ids", .array(of: .json)) 16 | .field("prices", .dictionary(of: .float), .required) 17 | .create() 18 | } 19 | 20 | func revert(on database: Database) -> EventLoopFuture { 21 | return database.schema(MakeuperModel.schema).delete() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateStylists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 14.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateStylists: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | return database.schema(StylistModel.schema) 13 | .id() 14 | .field("name", .string, .required) 15 | .field("platform_ids", .array(of: .json)) 16 | .field("prices", .dictionary(of: .float), .required) 17 | .create() 18 | } 19 | 20 | func revert(on database: Database) -> EventLoopFuture { 21 | return database.schema(StylistModel.schema).delete() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Models/ReviewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 03.04.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | import Botter 12 | import ValidatedPropertyKit 13 | 14 | final class ReviewModel: Model, ReviewProtocol { 15 | typealias TwinType = Review 16 | 17 | static let schema = "reviews" 18 | 19 | @ID(key: .id) 20 | var id: UUID? 21 | 22 | @OptionalParent(key: "screenshot") 23 | var _screenshot: PlatformFileModel! 24 | 25 | var screenshot: PlatformFileModel! { 26 | get { _screenshot } 27 | set { $_screenshot.id = newValue.id } 28 | } 29 | 30 | required init() { } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Buildable/Buildable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 03.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol BuildableModel: Codable { 11 | init(from buildable: T) throws 12 | } 13 | 14 | extension BuildableModel { 15 | init(from buildable: T) throws { 16 | self = try JSONDecoder.snakeCased.decode(Self.self, from: try JSONEncoder.snakeCased.encode(buildable)) 17 | } 18 | } 19 | 20 | protocol Buildable: Codable { 21 | init() 22 | 23 | var modelType: BuildableModel.Type { get } 24 | } 25 | 26 | extension Buildable { 27 | func toModel() throws -> BuildableModel { 28 | try modelType.init(from: self) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/App/Models/Siblings/StudioPhoto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | 12 | final class StudioPhoto: Model { 13 | static let schema = "studios+platform_files" 14 | 15 | @ID(key: .id) 16 | var id: UUID? 17 | 18 | @Parent(key: "studio_id") 19 | var studio: StudioModel 20 | 21 | @Parent(key: "photo_id") 22 | var photo: PlatformFileModel 23 | 24 | init() { } 25 | 26 | init(id: UUID? = nil, stylist: StudioModel, photo: PlatformFileModel) throws { 27 | self.id = id 28 | self.$studio.id = try studio.requireID() 29 | self.$photo.id = try photo.requireID() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Models/Siblings/OrderPromotion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | 12 | final class OrderPromotion: Model { 13 | static let schema = "orders+promotions" 14 | 15 | @ID(key: .id) 16 | var id: UUID? 17 | 18 | @Parent(key: "order_id") 19 | var order: OrderModel 20 | 21 | @Parent(key: "promotion_id") 22 | var promotion: PromotionModel 23 | 24 | init() { } 25 | 26 | init(id: UUID? = nil, order: OrderModel, promotion: PromotionModel) throws { 27 | self.id = id 28 | self.$order.id = try order.requireID() 29 | self.$promotion.id = try promotion.requireID() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Buildable/HelperProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 16.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ArrayProtocol { 11 | static var elementType: Any.Type { get } 12 | var elements: [Any] { get } 13 | } 14 | 15 | extension Array: ArrayProtocol { 16 | static var elementType: Any.Type { Element.self } 17 | var elements: [Any] { self } 18 | } 19 | 20 | protocol OptionalProtocol { 21 | var myWrappedType: Any.Type { get } 22 | var myWrapped: Any? { get } 23 | } 24 | 25 | extension Optional: OptionalProtocol { 26 | var myWrappedType: Any.Type { 27 | Wrapped.self 28 | } 29 | 30 | var myWrapped: Any? { 31 | wrapped 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/App/Models/Siblings/MakeuperPhoto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | 12 | final class MakeuperPhoto: Model { 13 | static let schema = "makeupers+platform_files" 14 | 15 | @ID(key: .id) 16 | var id: UUID? 17 | 18 | @Parent(key: "makeuper_id") 19 | var makeuper: MakeuperModel 20 | 21 | @Parent(key: "photo_id") 22 | var photo: PlatformFileModel 23 | 24 | init() { } 25 | 26 | init(id: UUID? = nil, stylist: MakeuperModel, photo: PlatformFileModel) throws { 27 | self.id = id 28 | self.$makeuper.id = try makeuper.requireID() 29 | self.$photo.id = try photo.requireID() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Type/ModeledType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.01.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Botter 11 | import Vapor 12 | import ValidatedPropertyKit 13 | 14 | enum ModeledTypeError: Error { 15 | case validationError(_ type: Any) 16 | } 17 | 18 | protocol ModeledType: Twinable where TwinType: Model { 19 | 20 | func save(app: Application) throws -> Future 21 | 22 | var isValid: Bool { get } 23 | } 24 | 25 | extension ModeledType { 26 | var isValid: Bool { true } 27 | 28 | func saveReturningId(app: Application) throws -> Future { 29 | try save(app: app).flatMapThrowing { try $0.requireID() } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Models/Siblings/StylistPhoto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | 12 | final class StylistPhoto: Model { 13 | static let schema = "stylists+platform_files" 14 | 15 | @ID(key: .id) 16 | var id: UUID? 17 | 18 | @Parent(key: "stylist_id") 19 | var stylist: StylistModel 20 | 21 | @Parent(key: "photo_id") 22 | var photo: PlatformFileModel 23 | 24 | init() { } 25 | 26 | init(id: UUID? = nil, stylist: StylistModel, photo: PlatformFileModel) throws { 27 | self.id = id 28 | 29 | self.$stylist.id = try stylist.requireID() 30 | self.$photo.id = try photo.requireID() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Systemic/CreateEventPayloads.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.03.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateEventPayloads: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | database.schema(EventPayloadModel.schema) 13 | .id() 14 | .field("instance", .string, .required) 15 | .field("owner_id", .uuid, .required, .references(UserModel.schema, .id)) 16 | .field("node_id", .uuid, .required, .references(NodeModel.schema, .id)) 17 | .create() 18 | } 19 | 20 | func revert(on database: Database) -> EventLoopFuture { 21 | database.schema(EventPayloadModel.schema).delete() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Models/Siblings/PhotographerPhoto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | 12 | final class PhotographerPhoto: Model { 13 | static let schema = "photographers+platform_files" 14 | 15 | @ID(key: .id) 16 | var id: UUID? 17 | 18 | @Parent(key: "photographer_id") 19 | var photographer: PhotographerModel 20 | 21 | @Parent(key: "photo_id") 22 | var photo: PlatformFileModel 23 | 24 | init() { } 25 | 26 | init(id: UUID? = nil, stylist: PhotographerModel, photo: PlatformFileModel) throws { 27 | self.id = id 28 | self.$photographer.id = try photographer.requireID() 29 | self.$photo.id = try photo.requireID() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Types/NodeAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.01.2021. 6 | // 7 | 8 | import Foundation 9 | import AnyCodable 10 | 11 | public enum NodeActionType: String, Codable { 12 | case messageEdit 13 | case createNode 14 | case uploadPhoto 15 | case applyPromocode 16 | case handleCalendar 17 | case handleOrderAgreement 18 | } 19 | 20 | public struct NodeAction: Codable { 21 | 22 | let type: NodeActionType 23 | 24 | enum Action: AutoCodable { 25 | case push(target: PushTarget) 26 | case pop 27 | } 28 | 29 | let action: Action? 30 | 31 | init(_ type: NodeActionType, success action: Action? = nil) { 32 | self.type = type 33 | self.action = action 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreateAgreements.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.06.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | 11 | struct CreateAgreements: Migration { 12 | func prepare(on database: Database) -> EventLoopFuture { 13 | return database.schema(AgreementModel.schema) 14 | .id() 15 | .field("order_id", .uuid, .required, .references(OrderModel.schema, .id)) 16 | .field("approver_id", .uuid, .required, .references(UserModel.schema, .id)) 17 | .unique(on: "order_id", "approver_id") 18 | .create() 19 | } 20 | 21 | func revert(on database: Database) -> EventLoopFuture { 22 | return database.schema(AgreementModel.schema).delete() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/macos.yml: -------------------------------------------------------------------------------- 1 | name: MacOS 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | needs: [build] 23 | 24 | steps: 25 | - name: Deployment 26 | uses: appleboy/ssh-action@master 27 | with: 28 | host: ${{ secrets.HOST }} 29 | username: ${{ secrets.USERNAME }} 30 | password: ${{ secrets.PASSWORD }} 31 | script: | 32 | cd ${{ secrets.REPO_PATH }} 33 | git pull 34 | swift build -c release 35 | /opt/homebrew/bin/supervisorctl restart photobot 36 | -------------------------------------------------------------------------------- /Sources/App/Types/Review.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 03.04.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | 14 | final class Review: ReviewProtocol { 15 | 16 | typealias TwinType = ReviewModel 17 | 18 | var id: UUID? 19 | 20 | var screenshot: PlatformFileModel! 21 | 22 | required init() {} 23 | 24 | } 25 | 26 | extension Review: ModeledType { 27 | var isValid: Bool { 28 | true 29 | } 30 | 31 | func save(app: Application) throws -> EventLoopFuture { 32 | guard isValid else { 33 | throw ModeledTypeError.validationError(self) 34 | } 35 | return TwinType.create(other: self, app: app) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/App/Models/Siblings/AgreementModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 02.06.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | import Botter 12 | import ValidatedPropertyKit 13 | 14 | final class AgreementModel: Model { 15 | static var schema: String = "agreements" 16 | 17 | @ID(key: .id) 18 | var id: UUID? 19 | 20 | @Parent(key: "order_id") 21 | var order: OrderModel 22 | 23 | @Parent(key: "approver_id") 24 | var approver: UserModel 25 | 26 | required init() {} 27 | 28 | init(id: UUID? = nil, order: OrderModel, approver: UserModel) throws { // TODO: model args to ids 29 | self.id = id 30 | self.$order.id = try order.requireID() 31 | self.$approver.id = try approver.requireID() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/App/Modules/Welcome/ShowcaseNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class ShowcaseNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Showcase node", 16 | messagesGroup: [ 17 | .init(text: "Это - бот Насти Царевой. Тут ты сможешь посмотреть мое портфолио, отзывы, заказать сьемку и многое другое.", keyboard: [[ 18 | try .init(text: "🔥 Вперед", action: .callback, eventPayload: .push(.entryPoint(.welcome))) 19 | ]]) 20 | ], 21 | entryPoint: .showcase, 22 | app: app 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/App/Models/Systemic/EventPayloadModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | import Botter 12 | import ValidatedPropertyKit 13 | 14 | final class EventPayloadModel: Model { 15 | 16 | static var schema: String = "event_payloads" 17 | 18 | @ID(key: .id) 19 | var id: UUID? 20 | 21 | @Field(key: "instance") 22 | var instance: String 23 | 24 | @Parent(key: "owner_id") 25 | var owner: UserModel 26 | 27 | @Parent(key: "node_id") 28 | var node: NodeModel 29 | 30 | required init() {} 31 | 32 | init(instance: String, ownerId: UUID, nodeId: UUID) { 33 | self.instance = instance 34 | self.$owner.id = ownerId 35 | self.$node.id = nodeId 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateStudios.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 23.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateStudios: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | database.schema(StudioModel.schema) 13 | .id() 14 | .field("name", .string, .required) 15 | .field("description", .string, .required) 16 | .field("address", .string, .required) 17 | .field("coords", .json, .required) 18 | .field("prices", .dictionary(of: .float), .required) 19 | .field("platform_ids", .array(of: .json)) 20 | .create() 21 | } 22 | 23 | func revert(on database: Database) -> EventLoopFuture { 24 | database.schema(StudioModel.schema).delete() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateNodes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 08.01.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateNodes: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | return database.schema(NodeModel.schema) 13 | .id() 14 | .field("systemic", .bool, .required) 15 | .field("name", .string, .required) 16 | .field("messages", .json, .required) 17 | .field("entry_point", .string) 18 | .field("closeable", .bool, .required) 19 | .field("action", .json) 20 | .unique(on: "entry_point") 21 | .create() 22 | } 23 | 24 | func revert(on database: Database) -> EventLoopFuture { 25 | return database.schema(NodeModel.schema).delete() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreatePromotions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 24.02.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreatePromotions: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | database.schema(PromotionModel.schema) 13 | .id() 14 | .field("auto_apply", .bool, .required) 15 | .field("name", .string, .required) 16 | .field("description", .string, .required) 17 | .field("promocode", .string) 18 | .field("condition", .json, .required) 19 | .field("impact", .json, .required) 20 | .unique(on: "promocode") 21 | .create() 22 | } 23 | 24 | func revert(on database: Database) -> EventLoopFuture { 25 | database.schema(PromotionModel.schema).delete() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/App/Modules/Welcome/WelcomeNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class WelcomeNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Welcome guest node", 16 | messagesGroup: [ 17 | .init(text: "Привет, " + .replacing(by: .userFirstName) + "! Похоже ты тут впервые) Хочешь узнать что делает этот бот?", keyboard: [[ 18 | try .init(text: "Да", action: .callback, eventPayload: .push(.entryPoint(.showcase))), 19 | try .init(text: "Нет", action: .callback, eventPayload: .push(.entryPoint(.welcome))) 20 | ]]) 21 | ], 22 | entryPoint: .welcomeGuest, app: app 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Buildable/BuildableField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 16.03.2021. 6 | // 7 | 8 | import Foundation 9 | import AnyCodable 10 | 11 | protocol BuildableField { 12 | static func check(_ str: String) -> Bool 13 | static func value(_ str: String) -> AnyCodable 14 | } 15 | 16 | extension String: BuildableField { 17 | static func value(_ str: String) -> AnyCodable { .init(str) } 18 | 19 | static func check(_ str: String) -> Bool { !str.isEmpty } 20 | } 21 | 22 | extension Bool: BuildableField { 23 | static func value(_ str: String) -> AnyCodable { .init(str == "+") } 24 | 25 | static func check(_ str: String) -> Bool { str == "+" || str == "-" } 26 | } 27 | 28 | extension Optional: BuildableField where Wrapped: BuildableField { 29 | static func check(_ str: String) -> Bool { Wrapped.check(str) } 30 | 31 | static func value(_ str: String) -> AnyCodable { Wrapped.value(str) } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/App/Models/PlatformFileModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Fluent 11 | import Vapor 12 | 13 | typealias AttachableFileSiblings = SiblingsProperty 14 | 15 | final class PlatformFileModel: Model, PlatformFileProtocol { 16 | static let schema = "platform_files" 17 | 18 | typealias TwinType = PlatformFile 19 | 20 | @ID(key: .id) 21 | var id: UUID? 22 | 23 | @Siblings(through: StylistPhoto.self, from: \.$photo, to: \.$stylist) 24 | var stylists: [StylistModel] 25 | 26 | @Siblings(through: MakeuperPhoto.self, from: \.$photo, to: \.$makeuper) 27 | var makeupers: [MakeuperModel] 28 | 29 | @Field(key: "platform_entries") 30 | var platformEntries: [Entry]? 31 | 32 | @Field(key: "type") 33 | var type: FileInfoType? 34 | 35 | required init() {} 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Modules/Main/PortfolioNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class PortfolioNodeController: NodeController { 13 | func create(app: Application) -> Future { 14 | Node.create( 15 | name: "Portfolio node", 16 | messagesGroup: [ 17 | .init(text: "Test message here.") 18 | ], 19 | entryPoint: .portfolio, app: app 20 | ) 21 | } 22 | 23 | 24 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> EventLoopFuture<[SendMessage]>? { 25 | guard listType == .portfolio else { return nil } 26 | //let (app, user) = (context.app, context.user) 27 | 28 | return context.app.eventLoopGroup.future([]) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/App/Twinable/ReviewProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 03.04.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol ReviewProtocol: Twinable where TwinType: ReviewProtocol { 14 | 15 | var id: UUID? { get set } 16 | var screenshot: PlatformFileModel! { get set } 17 | 18 | init() 19 | static func create(id: UUID?, screenshot: PlatformFileModel, app: Application) -> Future 20 | } 21 | 22 | extension ReviewProtocol { 23 | static func create(other: TwinType, app: Application) -> Future { 24 | Self.create(id: other.id, screenshot: other.screenshot, app: app) 25 | } 26 | 27 | static func create(id: UUID? = nil, screenshot: PlatformFileModel, app: Application) -> Future { 28 | let instance = Self.init() 29 | instance.id = id 30 | instance.screenshot = screenshot 31 | return instance.saveIfNeeded(app: app) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreateSiblingPhotos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 23.02.2021. 6 | // 7 | 8 | import Fluent 9 | import Vapor 10 | 11 | protocol CreateSiblingPhotos: Migration { 12 | associatedtype TwinType: PhotosProtocol & ModeledType 13 | 14 | var name: String { get } 15 | } 16 | 17 | extension CreateSiblingPhotos { 18 | func prepare(on database: Database) -> EventLoopFuture { 19 | database.schema(TwinType.SiblingModel.schema) 20 | .id() 21 | .field(.init(stringLiteral: "\(name)_id"), .uuid, .required, .references(TwinType.TwinType.schema, "id")) 22 | .field("photo_id", .uuid, .required, .references(PlatformFileModel.schema, "id")) 23 | .unique(on: .init(stringLiteral: "\(name)_id"), "photo_id") 24 | .create() 25 | } 26 | 27 | func revert(on database: Database) -> EventLoopFuture { 28 | database.schema(TwinType.SiblingModel.schema).delete() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Array+shift.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | 12 | /** 13 | Returns a new array with the first elements up to specified distance being shifted to the end of the collection. If the distance is negative, returns a new array with the last elements up to the specified absolute distance being shifted to the beginning of the collection. 14 | 15 | If the absolute distance exceeds the number of elements in the array, the elements are not shifted. 16 | */ 17 | func shift(withDistance distance: Int = 1) -> Array { 18 | let offsetIndex = distance >= 0 ? 19 | self.index(startIndex, offsetBy: distance, limitedBy: endIndex) : 20 | self.index(endIndex, offsetBy: distance, limitedBy: startIndex) 21 | 22 | guard let index = offsetIndex else { return self } 23 | return Array(self[index ..< endIndex] + self[startIndex ..< index]) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/App/Migrations/Siblings/CreateSiblingPromotions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.03.2021. 6 | // 7 | 8 | import Fluent 9 | import Vapor 10 | 11 | protocol CreateSiblingPromotions: Migration { 12 | associatedtype TwinType: PromotionsProtocol & ModeledType 13 | 14 | var name: String { get } 15 | } 16 | 17 | extension CreateSiblingPromotions { 18 | func prepare(on database: Database) -> EventLoopFuture { 19 | database.schema(TwinType.SiblingModel.schema) 20 | .id() 21 | .field(.init(stringLiteral: "\(name)_id"), .uuid, .required, .references(TwinType.TwinType.schema, "id")) 22 | .field("promotion_id", .uuid, .required, .references(PromotionModel.schema, "id")) 23 | .unique(on: .init(stringLiteral: "\(name)_id"), "promotion_id") 24 | .create() 25 | } 26 | 27 | func revert(on database: Database) -> EventLoopFuture { 28 | database.schema(TwinType.SiblingModel.schema).delete() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Buildable/NodeBuildable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 01.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NodeModel: BuildableModel {} 11 | extension SendMessage: BuildableModel {} 12 | extension SendMessageGroup: BuildableModel {} 13 | 14 | struct NodeBuildable: Buildable { 15 | var modelType: BuildableModel.Type { NodeModel.self } 16 | 17 | var name: String? = nil 18 | var systemic: Bool? = nil 19 | var test: [SendMessageBuildable]? = nil 20 | } 21 | 22 | //enum SendMessageGroupBuildable: Buildable { 23 | // var modelType: BuildableModel.Type { SendMessageGroup.self } 24 | // 25 | // case array(_ elements: [SendMessageBuildable]) 26 | // case builder 27 | //} 28 | 29 | struct SendMessageBuildable: Buildable { 30 | var modelType: BuildableModel.Type { SendMessage.self } 31 | 32 | var innertest: String? = nil 33 | var innertest2: String? = nil 34 | var innertest3: String? = nil 35 | var innertestBool: Bool? = nil 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Models/PromotionModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 24.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | 11 | typealias AttachablePromotionSiblings = SiblingsProperty 12 | 13 | final class PromotionModel: Model, PromotionProtocol { 14 | typealias TwinType = Promotion 15 | 16 | static let schema = "promotions" 17 | 18 | @ID(key: .id) 19 | var id: UUID? 20 | 21 | @Field(key: "auto_apply") 22 | var autoApply: Bool 23 | 24 | @Field(key: "name") 25 | var name: String? 26 | 27 | @Field(key: "description") 28 | var description: String? 29 | 30 | @Field(key: "promocode") 31 | var promocode: String? 32 | 33 | @Field(key: "impact") 34 | var impact: PromotionImpact! 35 | 36 | @Field(key: "condition") 37 | var condition: PromotionCondition! 38 | 39 | @Siblings(through: OrderPromotion.self, from: \.$promotion, to: \.$order) 40 | var orders: [OrderModel] 41 | 42 | required init() { } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nikolai Trukhin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/Makeuper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | import AnyCodable 14 | 15 | final class Makeuper: MakeuperProtocol { 16 | 17 | typealias TwinType = MakeuperModel 18 | 19 | var id: UUID? 20 | 21 | @Validated(.isLetters && .greater(1) && .less(25)) 22 | var name: String? 23 | 24 | var platformIds: [TypedPlatform] = [] 25 | 26 | var photos: [PlatformFileModel] = [] 27 | 28 | var prices: [OrderType: Float] = [:] 29 | 30 | var user: UserModel! 31 | 32 | required init() {} 33 | 34 | } 35 | 36 | extension Makeuper: ModeledType { 37 | var isValid: Bool { 38 | _name.isValid //&& _photos.isValid 39 | } 40 | 41 | func save(app: Application) throws -> EventLoopFuture { 42 | guard isValid else { 43 | throw ModeledTypeError.validationError(self) 44 | } 45 | return try TwinType.create(other: self, app: app) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Priceable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Priceable.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 05.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | 12 | protocol Priceable { 13 | var prices: [OrderType: Float] { get set } 14 | var _prices: [String: Float] { get set } 15 | } 16 | 17 | extension Priceable where Self: Model { 18 | var prices: [OrderType: Float] { 19 | get { 20 | .init(uniqueKeysWithValues: _prices.compactMap { key, value in 21 | guard let type = OrderType(rawValue: key) else { return nil } 22 | return (type, value) 23 | }) 24 | } 25 | mutating set { 26 | _prices = .init(uniqueKeysWithValues: newValue.compactMap { type, value in 27 | (type.rawValue, value) 28 | }) 29 | } 30 | } 31 | } 32 | 33 | extension Priceable where Self: Twinable, Self.TwinType: Model { 34 | var _prices: [String: Float] { get { [:] } set {} } 35 | } 36 | 37 | //extension Priceable { 38 | // var formattedPrice(): String { 39 | // "\(price) ₽ / час" 40 | // } 41 | //} 42 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/Photographer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | import AnyCodable 14 | 15 | final class Photographer: PhotographerProtocol { 16 | 17 | typealias TwinType = PhotographerModel 18 | 19 | var id: UUID? 20 | 21 | @Validated(.isLetters && .greater(1) && .less(25)) 22 | var name: String? 23 | 24 | var photos: [PlatformFileModel] = [] 25 | 26 | var prices: [OrderType: Float] = [:] 27 | 28 | var platformIds: [TypedPlatform] = [] 29 | 30 | var user: UserModel! 31 | 32 | required init() {} 33 | 34 | } 35 | 36 | extension Photographer: ModeledType { 37 | var isValid: Bool { 38 | _name.isValid //&& _photos.isValid 39 | } 40 | 41 | func save(app: Application) throws -> EventLoopFuture { 42 | guard isValid else { 43 | throw ModeledTypeError.validationError(self) 44 | } 45 | return try TwinType.create(other: self, app: app) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/Stylist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 14.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | import AnyCodable 14 | 15 | final class Stylist: StylistProtocol { 16 | 17 | typealias TwinType = StylistModel 18 | 19 | var id: UUID? 20 | 21 | @Validated(.greater(1)) 22 | var name: String? 23 | 24 | var platformIds: [TypedPlatform] = [] 25 | 26 | var photos: [PlatformFileModel] = [] 27 | 28 | var prices: [OrderType: Float] = [:] 29 | 30 | var user: UserModel! 31 | 32 | required init() {} 33 | 34 | // TODO: contact info (vk or tg id/username) 35 | 36 | } 37 | 38 | extension Stylist: ModeledType { 39 | var isValid: Bool { 40 | _name.isValid// && _photos.isValid 41 | } 42 | 43 | func save(app: Application) throws -> EventLoopFuture { 44 | guard isValid else { 45 | throw ModeledTypeError.validationError(self) 46 | } 47 | return try TwinType.create(other: self, app: app) 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/Studio.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | 14 | final class Studio: StudioProtocol { 15 | 16 | typealias TwinType = StudioModel 17 | 18 | var id: UUID? 19 | 20 | var name: String? 21 | 22 | var description: String? 23 | 24 | var address: String? 25 | 26 | var coords: Coords? 27 | 28 | var photos: [PlatformFileModel] = [] 29 | 30 | var prices: [OrderType: Float] = [:] 31 | 32 | // Platform identifiable 33 | 34 | var platformIds: [TypedPlatform] = [] 35 | 36 | var user: UserModel! 37 | 38 | required init() {} 39 | 40 | } 41 | 42 | extension Studio: ModeledType { 43 | 44 | var isValid: Bool { 45 | true//_photos.isValid 46 | } 47 | 48 | func save(app: Application) throws -> Future { 49 | guard isValid else { 50 | throw ModeledTypeError.validationError(self) 51 | } 52 | return try TwinType.create(other: self, app: app) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateOrders.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.03.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateOrders: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | return database.schema(OrderModel.schema) 13 | .id() 14 | .field("user_id", .uuid, .references(UserModel.schema, .id)) 15 | .field("type", .string, .required) 16 | .field("status", .string, .required) 17 | .field("stylist_id", .uuid, .references(StylistModel.schema, .id)) 18 | .field("makeuper_id", .uuid, .references(MakeuperModel.schema, .id)) 19 | .field("photographer_id", .uuid, .references(PhotographerModel.schema, .id)) 20 | .field("studio_id", .uuid, .references(StudioModel.schema, .id)) 21 | .field("hour_price", .float, .required) 22 | .field("start_date", .datetime, .required) 23 | .field("end_date", .datetime, .required) 24 | .create() 25 | } 26 | 27 | func revert(on database: Database) -> EventLoopFuture { 28 | return database.schema(OrderModel.schema).delete() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/App/Twinable/PlatformFileProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 26.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol PlatformFileProtocol: Twinable where TwinType: PlatformFileProtocol { 14 | typealias Entry = TypedPlatform 15 | 16 | var id: UUID? { get set } 17 | var platformEntries: [Entry]? { get set } 18 | var type: FileInfoType? { get set } 19 | 20 | init() 21 | static func create(id: UUID?, platformEntries: [Entry]?, type: FileInfoType?, app: Application) -> Future 22 | } 23 | 24 | extension PlatformFileProtocol { 25 | static func create(other: TwinType, app: Application) throws -> Future { 26 | Self.create(id: other.id, platformEntries: other.platformEntries, type: other.type, app: app) 27 | } 28 | 29 | static func create(id: UUID? = nil, platformEntries: [Entry]?, type: FileInfoType?, app: Application) -> Future { 30 | let instance = Self.init() 31 | instance.id = id 32 | instance.platformEntries = platformEntries 33 | instance.type = type 34 | return instance.saveIfNeeded(app: app) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Twinable/Base/UsersProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol UsersProtocol: class { 14 | associatedtype ImplementingModel: Model & UsersProtocol 15 | 16 | var user: UserModel! { get set } 17 | var usersProperty: ChildrenProperty? { get } 18 | } 19 | 20 | extension UsersProtocol where Self: Twinable, Self.TwinType == ImplementingModel { 21 | var usersProperty: ChildrenProperty? { nil } 22 | } 23 | 24 | extension UsersProtocol { 25 | func getUser(app: Application) -> Future { 26 | usersProperty?.get(on: app.db).map(\.first) ?? app.eventLoopGroup.future(user) 27 | } 28 | 29 | func attachUser(_ user: UserModel, app: Application) throws -> Future { 30 | if let _ = self as? AnyModel { 31 | guard let property = self.usersProperty else { fatalError("Users property must be implemented") } 32 | return property.create(user, on: app.db) 33 | } else { 34 | self.user = user 35 | return app.eventLoopGroup.future() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateUsers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 08.01.2021. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateUsers: Migration { 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | return database.schema(UserModel.schema) 13 | .id() 14 | .field("is_admin", .bool, .required) 15 | .field("first_name", .string) 16 | .field("last_name", .string) 17 | .field("platform_ids", .array(of: .json)) 18 | .field("history", .array(of: .json), .required) 19 | .field("node_id", .uuid, .references(NodeModel.schema, "id")) 20 | .field("node_payload", .json) 21 | .field("makeuper_id", .uuid, .references(MakeuperModel.schema, .id)) 22 | .field("stylist_id", .uuid, .references(StylistModel.schema, .id)) 23 | .field("photographer_id", .uuid, .references(PhotographerModel.schema, .id)) 24 | .field("studio_id", .uuid, .references(StudioModel.schema, .id)) 25 | .field("last_destination", .json) 26 | .create() 27 | } 28 | 29 | func revert(on database: Database) -> EventLoopFuture { 30 | return database.schema(UserModel.schema).delete() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/App/Models/MakeuperModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | import Botter 12 | import ValidatedPropertyKit 13 | 14 | final class MakeuperModel: Model, MakeuperProtocol { 15 | typealias TwinType = Makeuper 16 | 17 | static var schema: String = "makeupers" 18 | 19 | @ID(key: .id) 20 | var id: UUID? 21 | 22 | @OptionalField(key: "name") 23 | var name: String? 24 | 25 | @Field(key: "platform_ids") 26 | var platformIds: [TypedPlatform] 27 | 28 | @Field(key: "prices") 29 | var _prices: [String: Float] 30 | 31 | @Siblings(through: MakeuperPhoto.self, from: \.$makeuper, to: \.$photo) 32 | var _photos: [PlatformFileModel] 33 | 34 | var photosSiblings: AttachableFileSiblings? { $_photos } 35 | 36 | var photos: [PlatformFileModel] { 37 | get { _photos } 38 | set { _photos = newValue.compactMap { $0 } } 39 | } 40 | 41 | @Children(for: \.$_makeuper) 42 | var users: [UserModel] 43 | 44 | var user: UserModel! { 45 | get { users.first } 46 | set { fatalError() } 47 | } 48 | 49 | var usersProperty: ChildrenProperty? { $users } 50 | 51 | required init() {} 52 | } 53 | -------------------------------------------------------------------------------- /Sources/App/Models/StylistModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 14.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | import Botter 12 | import ValidatedPropertyKit 13 | 14 | final class StylistModel: Model, StylistProtocol { 15 | 16 | typealias TwinType = Stylist 17 | 18 | static var schema: String = "stylists" 19 | 20 | @ID(key: .id) 21 | var id: UUID? 22 | 23 | @OptionalField(key: "name") 24 | var name: String? 25 | 26 | @Field(key: "platform_ids") 27 | var platformIds: [TypedPlatform] 28 | 29 | @Field(key: "prices") 30 | var _prices: [String: Float] 31 | 32 | @Siblings(through: StylistPhoto.self, from: \.$stylist, to: \.$photo) 33 | var _photos: [PlatformFileModel] 34 | 35 | var photos: [PlatformFileModel] { 36 | get { _photos } 37 | set { fatalError("Siblings must be attached manually") } 38 | } 39 | 40 | var photosSiblings: AttachableFileSiblings? { $_photos } 41 | 42 | @Children(for: \.$_stylist) 43 | var users: [UserModel] 44 | 45 | var user: UserModel! { 46 | get { users.first } 47 | set { fatalError() } 48 | } 49 | 50 | var usersProperty: ChildrenProperty? { $users } 51 | 52 | required init() {} 53 | } 54 | -------------------------------------------------------------------------------- /Sources/App/Models/PhotographerModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | import Botter 12 | import ValidatedPropertyKit 13 | 14 | final class PhotographerModel: Model, PhotographerProtocol { 15 | typealias TwinType = Photographer 16 | 17 | static var schema: String = "photographers" 18 | 19 | @ID(key: .id) 20 | var id: UUID? 21 | 22 | @OptionalField(key: "name") 23 | var name: String? 24 | 25 | @Field(key: "platform_ids") 26 | var platformIds: [TypedPlatform] 27 | 28 | @Field(key: "prices") 29 | var _prices: [String: Float] 30 | 31 | @Siblings(through: PhotographerPhoto.self, from: \.$photographer, to: \.$photo) 32 | var _photos: [PlatformFileModel] 33 | 34 | var photosSiblings: AttachableFileSiblings? { $_photos } 35 | 36 | var photos: [PlatformFileModel] { 37 | get { _photos } 38 | set { _photos = newValue.compactMap { $0 } } 39 | } 40 | 41 | @Children(for: \.$_photographer) 42 | var users: [UserModel] 43 | 44 | var user: UserModel! { 45 | get { users.first } 46 | set { fatalError() } 47 | } 48 | 49 | var usersProperty: ChildrenProperty? { $users } 50 | 51 | required init() {} 52 | } 53 | -------------------------------------------------------------------------------- /Sources/App/Twinable/Base/PromotionsProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol PromotionsProtocol: class { 14 | 15 | associatedtype ImplementingModel: Model & PromotionsProtocol 16 | associatedtype SiblingModel: Model 17 | 18 | var promotions: [PromotionModel] { get set } 19 | var promotionsSiblings: AttachablePromotionSiblings? { get } 20 | } 21 | 22 | extension PromotionsProtocol { 23 | func getPromotions(app: Application) -> Future<[PromotionModel]> { 24 | promotionsSiblings?.get(on: app.db) ?? app.eventLoopGroup.future(promotions) 25 | } 26 | 27 | var promotionsSiblings: AttachablePromotionSiblings? { nil } 28 | 29 | func attachPromotions(_ promotions: [PromotionModel]?, app: Application) throws -> Future { 30 | guard let promotions = promotions else { return app.eventLoopGroup.future() } 31 | 32 | if let _ = self as? AnyModel { 33 | guard let siblings = self.promotionsSiblings else { fatalError("Promotions siblings must be implemented") } 34 | return try promotions.attach(to: siblings, app: app) 35 | } else { 36 | self.promotions = promotions 37 | return app.eventLoopGroup.future() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/Promotion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 24.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | 14 | final class Promotion: PromotionProtocol { 15 | 16 | typealias TwinType = PromotionModel 17 | 18 | var id: UUID? 19 | 20 | var autoApply: Bool = false 21 | 22 | @Validated(.nonEmpty) 23 | var name: String? 24 | 25 | @Validated(.nonEmpty) 26 | var description: String? 27 | 28 | @Validated(.nonEmpty) 29 | var promocode: String? 30 | 31 | var impact: PromotionImpact! 32 | 33 | var condition: PromotionCondition! 34 | 35 | required init() {} 36 | 37 | } 38 | 39 | extension Promotion: ModeledType { 40 | 41 | var isValid: Bool { 42 | _name.isValid 43 | } 44 | 45 | func save(app: Application) throws -> Future { 46 | guard isValid else { 47 | throw ModeledTypeError.validationError(self) 48 | } 49 | return try TwinType.create(other: self, app: app) 50 | } 51 | } 52 | 53 | extension Promotion { 54 | static func find(promocode: String, app: Application) -> Future { 55 | PromotionModel.query(on: app.db).filter(\.$promocode, .equal, promocode.trimmingCharacters(in: .whitespaces)).first().optionalThrowingFlatMap { try Promotion.create(other: $0, app: app) } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/App/Types/SendMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 13.01.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | public class SendMessage: Codable { 13 | 14 | public var destination: SendDestination? 15 | 16 | /// Текст личного сообщения. 17 | public var text: String? 18 | 19 | public var formatText: Bool 20 | 21 | /// Объект, описывающий клавиатуру бота. 22 | public var keyboard: Keyboard 23 | 24 | /// Вложения прикрепленные к сообщению. 25 | public var attachments: [FileInfo]? 26 | 27 | var params: Bot.SendMessageParams? { 28 | Bot.SendMessageParams(to: self, text: text, keyboard: keyboard, attachments: attachments) 29 | } 30 | 31 | convenience init(to replyable: Replyable, text: String? = nil, formatText: Bool = true, keyboard: Keyboard = .init(), attachments: [FileInfo]? = nil) { 32 | self.init(destination: replyable.destination, text: text, keyboard: keyboard, attachments: attachments) 33 | } 34 | 35 | init(destination: SendDestination? = nil, text: String? = nil, formatText: Bool = true, keyboard: Keyboard = .init(), attachments: [FileInfo]? = nil) { 36 | self.destination = destination 37 | self.text = text 38 | self.formatText = formatText 39 | self.keyboard = keyboard 40 | self.attachments = attachments 41 | } 42 | } 43 | 44 | extension SendMessage: Replyable {} 45 | -------------------------------------------------------------------------------- /Sources/App/Twinable/Base/PhotosProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 02.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol PhotosProtocol: class { 14 | 15 | associatedtype ImplementingModel: Model & PhotosProtocol 16 | associatedtype SiblingModel: Model 17 | 18 | var photos: [PlatformFileModel] { get set } 19 | var photosSiblings: AttachableFileSiblings? { get } 20 | } 21 | 22 | extension PhotosProtocol where Self: Twinable, Self.TwinType == ImplementingModel { 23 | var photosSiblings: AttachableFileSiblings? { nil } 24 | } 25 | 26 | extension PhotosProtocol { 27 | func getPhotos(app: Application) -> Future<[PlatformFileModel]> { 28 | photosSiblings?.get(on: app.db) ?? app.eventLoopGroup.future(photos) 29 | } 30 | 31 | func attachPhotos(_ photos: [PlatformFileModel]?, app: Application) throws -> Future { 32 | guard let photos = photos else { return app.eventLoopGroup.future() } 33 | 34 | if let model = self as? ImplementingModel { 35 | guard let siblings = model.photosSiblings else { fatalError("Photos siblings must be implemented") } 36 | return try photos.attach(to: siblings, app: app) 37 | } else { 38 | self.photos = photos 39 | return app.eventLoopGroup.future() 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/Order.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.03.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | 14 | public final class Order: OrderProtocol { 15 | 16 | typealias TwinType = OrderModel 17 | 18 | var id: UUID? 19 | var userId: UUID! 20 | var type: OrderType! 21 | var status: OrderStatus = .inAgreement 22 | var stylistId: UUID? 23 | var makeuperId: UUID? 24 | var photographerId: UUID? 25 | var studioId: UUID? 26 | var interval: DateInterval = .init() 27 | var hourPrice: Float = 0 28 | var promotions: [PromotionModel] = [] 29 | 30 | required init() {} 31 | 32 | } 33 | 34 | extension Order: ModeledType { 35 | var isValid: Bool { 36 | true 37 | } 38 | 39 | var watcherIds: [UUID] { 40 | [makeuperId, photographerId, studioId].compactMap { $0 } 41 | } 42 | 43 | func cancelAvailable(user: User) -> Bool { 44 | guard status == .inAgreement || status == .inProgress else { return false } 45 | return user.isAdmin || user.watcherIds.contains { watcherIds.contains($0) } || user.id == userId 46 | } 47 | 48 | func save(app: Application) throws -> EventLoopFuture { 49 | guard isValid else { 50 | throw ModeledTypeError.validationError(self) 51 | } 52 | return try TwinType.create(other: self, app: app) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/PlatformFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 21.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | 14 | final class PlatformFile: PlatformFileProtocol { 15 | 16 | typealias TwinType = PlatformFileModel 17 | 18 | var id: UUID? 19 | 20 | @Validated(.contains(.tg, .vk)) 21 | var platformEntries: [Entry]? 22 | 23 | var type: FileInfoType? 24 | 25 | required init() {} 26 | 27 | } 28 | 29 | extension PlatformFile: ModeledType { 30 | 31 | var isValid: Bool { 32 | _platformEntries.isValid 33 | } 34 | 35 | func save(app: Application) throws -> EventLoopFuture { 36 | guard isValid else { 37 | throw ModeledTypeError.validationError(self) 38 | } 39 | return try TwinType.create(other: self, app: app) 40 | } 41 | } 42 | 43 | extension PlatformFile { 44 | 45 | var fileInfo: FileInfo? { 46 | guard let platformEntries = platformEntries, let type = type else { return nil } 47 | return .init(type: type, content: .fileId(.init(platformEntries))) 48 | } 49 | 50 | } 51 | 52 | extension Array where Element: Model { 53 | func attach(to: SiblingsProperty, app: Application) throws -> Future { 54 | compactMap { to.attach($0, on: app.db) }.flatten(on: app.eventLoopGroup.next()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/App/Models/NodeModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeModel.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 08.01.2021. 6 | // 7 | 8 | import Fluent 9 | import Vapor 10 | import Botter 11 | 12 | final class NodeModel: Model, NodeProtocol { 13 | static let schema = "nodes" 14 | 15 | typealias TwinType = Node 16 | 17 | @ID(key: .id) 18 | var id: UUID? 19 | 20 | @Field(key: "systemic") 21 | var systemic: Bool 22 | 23 | @Field(key: "name") 24 | var name: String? 25 | 26 | @Field(key: "messages") 27 | var messagesGroup: SendMessageGroup! 28 | 29 | @OptionalField(key: "entry_point") 30 | var entryPoint: EntryPoint? 31 | 32 | @OptionalField(key: "action") 33 | var action: NodeAction? 34 | 35 | @Field(key: "closeable") 36 | var closeable: Bool 37 | 38 | required init() { } 39 | 40 | public static func find( 41 | _ target: PushTarget, 42 | on database: Database 43 | ) -> Future { 44 | switch target { 45 | case let .id(id): 46 | return find(id, on: database).unwrap(or: PhotoBotError.nodeByIdNotFound) 47 | 48 | case let .entryPoint(entryPoint): 49 | return find(Node.entryPointIds[entryPoint], on: database).unwrap(or: PhotoBotError.nodeByIdNotFound) 50 | //return query(on: database).filter(\.$entryPoint == .enumCase(entryPoint.rawValue)).first() 51 | //.unwrap(or: PhotoBotError.nodeByEntryPointNotFound(entryPoint)) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Range+intervalDates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 10.03.2021. 6 | // 7 | 8 | import Foundation 9 | import DateHelper 10 | 11 | extension DateComponents { 12 | static func create(timeZone: TimeZone? = nil, era: Int? = nil, year: Int? = nil, month: Int? = nil, day: Int? = nil, hour: Int? = nil, minute: Int? = nil, second: Int? = nil, nanosecond: Int? = nil, weekday: Int? = nil, weekdayOrdinal: Int? = nil, quarter: Int? = nil, weekOfMonth: Int? = nil, weekOfYear: Int? = nil, yearForWeekOfYear: Int? = nil) -> Self { 13 | .init(calendar: .current, timeZone: timeZone, era: era, year: year, month: month, day: day, hour: hour, minute: minute, second: second, nanosecond: nanosecond, weekday: weekday, weekdayOrdinal: weekdayOrdinal, quarter: quarter, weekOfMonth: weekOfMonth, weekOfYear: weekOfYear, yearForWeekOfYear: yearForWeekOfYear) 14 | } 15 | } 16 | 17 | extension ClosedRange where Bound == Date { 18 | func intervalDates(_ dateComponent: DateComponentType, _ offset: Int) -> [Date] { 19 | let interval = Date(timeIntervalSince1970: 0).adjust(dateComponent, offset: offset).timeIntervalSince1970 20 | guard interval > 0 else { return [] } 21 | 22 | var dates:[Date] = [] 23 | var currentDate = lowerBound 24 | 25 | dates.append(currentDate) 26 | while currentDate < upperBound { 27 | currentDate = currentDate.addingTimeInterval(interval) 28 | dates.append(currentDate) 29 | } 30 | 31 | return dates 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/App/Protocols/PlatformIdentifiable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 11.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | protocol PlatformIdentifiable { 13 | var platformIds: [TypedPlatform] { get set } 14 | func getPlatformUser(app: Application) throws -> Future 15 | } 16 | 17 | extension PlatformIdentifiable where Self == UserModel { 18 | func getPlatformUser(app: Application) throws -> Future { 19 | app.eventLoopGroup.future(self) 20 | } 21 | } 22 | 23 | extension PlatformIdentifiable where Self == User { 24 | func getPlatformUser(app: Application) throws -> Future { 25 | try self.toTwin(app: app).map { .init($0) } 26 | } 27 | } 28 | 29 | extension PlatformIdentifiable where Self: UsersProtocol { 30 | func getPlatformUser(app: Application) throws -> Future { 31 | if let usersProperty = usersProperty { 32 | return usersProperty.get(on: app.db).map(\.first) 33 | } 34 | return app.eventLoopGroup.future(user) 35 | } 36 | } 37 | 38 | extension PlatformIdentifiable { 39 | /// Returns mention like `@someone` if target platform is same and URL to profile if not 40 | func platformLink(for platform: AnyPlatform) -> String? { 41 | if let samePlatformId = platformIds.first(platform: platform) { 42 | return samePlatformId.mention 43 | } else { 44 | return platformIds.first?.link 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/App/Models/StudioModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.02.2021. 6 | // 7 | 8 | import Fluent 9 | import Vapor 10 | import Botter 11 | 12 | final class StudioModel: Model, StudioProtocol { 13 | static let schema = "studios" 14 | 15 | typealias TwinType = Studio 16 | 17 | @ID(key: .id) 18 | var id: UUID? 19 | 20 | @Field(key: "name") 21 | var name: String? 22 | 23 | @Field(key: "description") 24 | var description: String? 25 | 26 | @Field(key: "address") 27 | var address: String? 28 | 29 | @Siblings(through: StudioPhoto.self, from: \.$studio, to: \.$photo) 30 | var _photos: [PlatformFileModel] 31 | 32 | var photos: [PlatformFileModel] { 33 | get { _photos } 34 | set { fatalError("Siblings must be attached manually") } 35 | } 36 | 37 | var photosSiblings: AttachableFileSiblings? { $_photos } 38 | 39 | @Field(key: "prices") 40 | var _prices: [String: Float] 41 | 42 | @Field(key: "coords") 43 | var coords: Coords? 44 | 45 | // Platform identifiable 46 | 47 | @Field(key: "platform_ids") 48 | var platformIds: [TypedPlatform] 49 | 50 | @Children(for: \.$_studio) 51 | var users: [UserModel] 52 | 53 | var user: UserModel! { 54 | get { users.first } 55 | set { fatalError() } 56 | } 57 | 58 | var usersProperty: ChildrenProperty? { $users } 59 | 60 | required init() { } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/App/Types/Modeled/Node.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 07.01.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import Vapor 12 | import Fluent 13 | 14 | public final class Node: NodeProtocol { 15 | 16 | typealias TwinType = NodeModel 17 | 18 | static var entryPointIds = [EntryPoint: UUID]() 19 | 20 | var id: UUID? 21 | 22 | var systemic: Bool = false 23 | 24 | @Validated(.greater(1)) 25 | var name: String? 26 | 27 | var messagesGroup: SendMessageGroup! 28 | 29 | var entryPoint: EntryPoint? 30 | 31 | var action: NodeAction? 32 | 33 | var closeable: Bool = true 34 | 35 | required init() {} 36 | } 37 | 38 | extension Node: ModeledType { 39 | var isValid: Bool { 40 | _name.isValid 41 | } 42 | 43 | func save(app: Application) throws -> EventLoopFuture { 44 | guard isValid else { 45 | throw ModeledTypeError.validationError(self) 46 | } 47 | return try TwinType.create(other: self, app: app) 48 | } 49 | } 50 | 51 | extension Node { 52 | public static func find( 53 | _ target: PushTarget, 54 | app: Application 55 | ) -> Future { 56 | TwinType.find(target, on: app.db).throwingFlatMap { try Node.create(other: $0, app: app) } 57 | } 58 | 59 | public static func findId( 60 | _ target: PushTarget, 61 | app: Application 62 | ) -> Future { 63 | Self.find(target, app: app).map(\.id!) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Twinable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 25.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | import Fluent 12 | 13 | protocol Twinable: class { 14 | associatedtype TwinType: Twinable 15 | 16 | static func create(other: TwinType, app: Application) throws -> Future 17 | 18 | func saveIfNeeded(app: Application) -> Future 19 | } 20 | 21 | extension Twinable where TwinType: Model { // non-model types 22 | func saveIfNeeded(app: Application) -> Future { 23 | app.eventLoopGroup.future(self) 24 | } 25 | 26 | static func find( 27 | _ id: TwinType.IDValue?, 28 | app: Application 29 | ) -> EventLoopFuture { 30 | TwinType.find(id, on: app.db).optionalThrowingFlatMap { try Self.create(other: $0, app: app) } 31 | } 32 | } 33 | 34 | extension Twinable where Self: Model { // model types 35 | func saveIfNeeded(app: Application) -> Future { 36 | if self.id != nil { 37 | self._$id.exists = true 38 | } 39 | return self.save(on: app.db).transform(to: self) 40 | } 41 | } 42 | 43 | extension Twinable where TwinType.TwinType == Self { 44 | func toTwin(app: Application) throws -> Future { 45 | try TwinType.create(other: self, app: app) 46 | } 47 | } 48 | 49 | extension Future where Value: Twinable, Value.TwinType.TwinType == Value { 50 | func toTwin(app: Application) -> Future { 51 | throwingFlatMap { try $0.toTwin(app: app) } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/App/Extensions/String+extractUrls.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 04.04.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | var extractUrls: [URL] { 12 | #if os(Linux) 13 | // Regex pattern from http://daringfireball.net/2010/07/improved_regex_for_matching_urls 14 | let pattern = "(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)" + 15 | "(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*" + 16 | "\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))" 17 | return matches(for: pattern).compactMap { URL(string: $0) } 18 | #else 19 | let types: NSTextCheckingResult.CheckingType = .link 20 | 21 | guard let detect = try? NSDataDetector(types: types.rawValue) else { 22 | return [] 23 | } 24 | 25 | let matches = detect.matches(in: self, options: .reportCompletion, range: NSMakeRange(0, count)) 26 | 27 | return matches.compactMap(\.url) 28 | #endif 29 | } 30 | 31 | #if os(Linux) 32 | func matches(for regex: String) -> [String] { 33 | 34 | do { 35 | let regex = try NSRegularExpression(pattern: regex) 36 | let results = regex.matches(in: self, 37 | range: NSRange(startIndex..., in: self)) 38 | return results.map { 39 | String(self[Range($0.range, in: self)!]) 40 | } 41 | } catch let error { 42 | print("invalid regex: \(error.localizedDescription)") 43 | return [] 44 | } 45 | } 46 | #endif 47 | } 48 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Decodable+init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 05.02.2021. 6 | // 7 | 8 | import Foundation 9 | import AnyCodable 10 | 11 | extension Decodable { 12 | init(from dict: [String: Any]) throws { 13 | self = try JSONDecoder.snakeCased.decode(Self.self, from: try JSONSerialization.data(withJSONObject: dict, options: [])) 14 | } 15 | 16 | init(from dict: [String: AnyCodable]) throws { 17 | try self.init(from: dict.unwrapped) 18 | } 19 | 20 | init(from string: String) throws { 21 | self = try JSONDecoder.snakeCased.decode(Self.self, from: .init(string: string)) 22 | } 23 | } 24 | 25 | extension Dictionary where Value == AnyCodable { 26 | private func mapFunc(_ wrapped: Value) -> Any { 27 | switch wrapped.value { 28 | case let wrapped as Self: 29 | return wrapped.unwrapped 30 | 31 | default: 32 | switch wrapped.value { 33 | case let arr as [AnyCodable]: 34 | return arr.map(mapFunc) 35 | 36 | default: 37 | return wrapped.value 38 | } 39 | } 40 | } 41 | 42 | var unwrapped: [Key: Any] { 43 | mapValues(mapFunc) 44 | } 45 | } 46 | 47 | extension Dictionary { 48 | var wrapped: [Key: AnyCodable] { 49 | mapValues { value -> AnyCodable in 50 | if let wrapped = value as? AnyCodable { 51 | if let childDict = wrapped.value as? [Key: Any] { 52 | return .init(childDict.wrapped) 53 | } 54 | 55 | return wrapped 56 | } 57 | return .init(value) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/App/Modules/NodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Botter 11 | 12 | public protocol NodeController { 13 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> Future<[Botter.Message]>? 14 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> Future<[SendMessage]>? 15 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> Future<([SendMessage], Int)>? 16 | func handleAction(_ action: NodeAction, _ message: Message, context: PhotoBotContextProtocol) throws -> EventLoopFuture>? 17 | func create(app: Application) throws -> Future 18 | } 19 | 20 | extension NodeController { 21 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> Future<[Botter.Message]>? { nil } 22 | func handleAction(_ action: NodeAction, _ message: Message, context: PhotoBotContextProtocol) throws -> EventLoopFuture>? { nil } 23 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> Future<[SendMessage]>? { nil } 24 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> Future<([SendMessage], Int)>? { nil } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/App/Modules/Main/MainNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 19.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class MainNodeController: NodeController { 13 | 14 | func create(app: Application) -> Future { 15 | Node.create( 16 | name: "Welcome node", 17 | messagesGroup: .welcome, 18 | entryPoint: .welcome, app: app 19 | ) 20 | } 21 | 22 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> Future<[SendMessage]>? { 23 | guard case .welcome = group else { return nil } 24 | 25 | return context.app.eventLoopGroup.future([ 26 | .init(text: "Добро пожаловать, " + .replacing(by: .userFirstName) + "! Выбери секцию чтобы в нее перейти.", keyboard: [ 27 | [ 28 | try .init(text: "👧 Обо мне", action: .callback, eventPayload: .push(.entryPoint(.about))), 29 | try .init(text: "🖼️ Мои работы", action: .callback, eventPayload: .push(.entryPoint(.portfolio))), 30 | ], 31 | [ 32 | try .init(text: "📷 Заказ фотосессии", action: .callback, eventPayload: .push(.entryPoint(.orderTypes))), 33 | try .init(text: "🌟 Отзывы", action: .callback, eventPayload: .push(.entryPoint(.reviews))), 34 | ], 35 | [ 36 | try .init(text: "📆 Заказы", action: .callback, eventPayload: .push(.entryPoint(.orders))), 37 | ] + (context.user.isAdmin ? [ 38 | try .init(text: "Выгрузить фотку", action: .callback, eventPayload: .push(.entryPoint(.uploadPhoto))), 39 | ] : []), 40 | ]) 41 | ]) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/OrderTypesNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderTypesNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order types node", 16 | messagesGroup: .orderTypes, 17 | entryPoint: .orderTypes, app: app 18 | ) 19 | } 20 | 21 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> EventLoopFuture<[SendMessage]>? { 22 | guard case .orderTypes = group else { return nil } 23 | 24 | let (app, user) = (context.app, context.user) 25 | 26 | return PhotographerModel.query(on: app.db).first().optionalThrowingFlatMap { try $0.toTwin(app: app) } 27 | .flatMapThrowing { photographer in 28 | [ 29 | .init(text: "Выберите тип фотосессии:", keyboard: [[ 30 | try .init(text: "❤️ Love story", action: .callback, eventPayload: .push(.entryPoint(.orderBuilder), payload: .orderBuilder(.init(with: nil, type: .loveStory, photographer: photographer, customer: user)))) 31 | ], [ 32 | try .init(text: "💼 Контент сьемка", action: .callback, eventPayload: .push(.entryPoint(.orderBuilder), payload: .orderBuilder(.init(with: nil, type: .content, photographer: photographer, customer: user)))) 33 | ], [ 34 | try .init(text: "👪 Семейная фотосессия", action: .callback, eventPayload: .push(.entryPoint(.orderBuilder), payload: .orderBuilder(.init(with: nil, type: .family, photographer: photographer, customer: user)))) 35 | ]]), 36 | ] 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/App/Modules/Main/ReviewsNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | enum ReviewsError { 13 | case screenshotNotFound 14 | } 15 | 16 | class ReviewsNodeController: NodeController { 17 | func create(app: Application) throws -> EventLoopFuture { 18 | Node.create( 19 | name: "Reviews node", 20 | messagesGroup: .list(.reviews), 21 | entryPoint: .reviews, app: app 22 | ) 23 | } 24 | 25 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> EventLoopFuture<([SendMessage], Int)>? { 26 | guard listType == .reviews else { return nil } 27 | let (app, _) = (context.app, context.user) 28 | 29 | return ReviewModel.query(on: app.db).count().flatMap { count in 30 | ReviewModel.query(on: app.db).range(indexRange).all().throwingFlatMap { orders in 31 | orders.enumerated().map { (index, order) -> Future in 32 | order.$_screenshot.get(on: app.db).throwingFlatMap { screenshot -> Future in 33 | guard let screenshot = screenshot else { return app.eventLoopGroup.future(nil) } 34 | return try screenshot.toTwin(app: app).map { screenshot in 35 | guard let attachment = screenshot.fileInfo else { return nil } 36 | return SendMessage( 37 | attachments: [ attachment ] 38 | ) 39 | } 40 | } 41 | } 42 | .flatten(on: app.eventLoopGroup.next()) 43 | .map { ($0.compactMap { $0 }, count) } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/App/TgEchoBot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.01.2021. 6 | // 7 | 8 | import Foundation 9 | import Telegrammer 10 | import TelegrammerMiddleware 11 | import Vkontakter 12 | import VkontakterMiddleware 13 | import Botter 14 | import Vapor 15 | 16 | class TgEchoBot { 17 | public let dispatcher: Telegrammer.Dispatcher 18 | public let bot: Telegrammer.Bot 19 | public let updater: Telegrammer.Updater 20 | 21 | public init(settings: Telegrammer.Bot.Settings) throws { 22 | self.bot = try .init(settings: settings) 23 | self.dispatcher = .init(bot: bot) 24 | self.updater = .init(bot: bot, dispatcher: dispatcher) 25 | 26 | dispatcher.add( 27 | handler: Telegrammer.MessageHandler( 28 | filters: .all, 29 | callback: echoResponse 30 | ) 31 | ) 32 | } 33 | 34 | func echoResponse(_ update: Telegrammer.Update, _ context: Telegrammer.BotContext?) throws { 35 | guard let message = update.message, 36 | let photos = message.photo, 37 | let biggestPhoto = photos.sorted(by: { $0.fileSize ?? 0 < $1.fileSize ?? 0 }).first else { 38 | return 39 | } 40 | 41 | // let params = Bot.SendMessageParams( 42 | // chatId: .chat(message.chat.id), 43 | // text: "Thats photo", 44 | // replyMarkup: .inlineKeyboardMarkup(.init(inlineKeyboard: [ [ .init(text: "test", callbackData: "fix:dgdfgd") ] ])) 45 | // ) 46 | 47 | try bot.sendMessage(params: .init(chatId: .chat(message.chat.id), text: biggestPhoto.fileId)) 48 | 49 | //if let data = try? Data(contentsOf: URL(string: "https://upload.wikimedia.org/wikipedia/commons/1/1e/Caerte_van_Oostlant_4MB.jpg")!) { 50 | try bot.sendPhoto(params: .init(chatId: .chat(message.chat.id), photo: .fileId(biggestPhoto.fileId), caption: "Thats photo")) 51 | //} 52 | 53 | 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/App/Twinable/StylistProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 27.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol StylistProtocol: PhotosProtocol, UsersProtocol, PlatformIdentifiable, Priceable, Twinable where TwinType: StylistProtocol { 14 | 15 | associatedtype ImplementingModel = StylistModel 16 | associatedtype SiblingModel = StylistPhoto 17 | 18 | var id: UUID? { get set } 19 | var name: String? { get set } 20 | var user: UserModel! { get set } 21 | 22 | init() 23 | static func create(id: UUID?, name: String?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel?, app: Application) -> Future 24 | } 25 | 26 | fileprivate enum StylistCreateError: Error { 27 | case noUser 28 | } 29 | 30 | extension StylistProtocol { 31 | static func create(other: TwinType, app: Application) throws -> Future { 32 | [ 33 | other.getUser(app: app).map { $0 as Any }, 34 | other.getPhotos(app: app).map { $0 as Any }, 35 | ].flatten(on: app.eventLoopGroup.next()).flatMap { 36 | let (user, photos) = ($0[0] as? UserModel, $0[1] as? [PlatformFileModel]) 37 | return Self.create(id: other.id, name: other.name, platformIds: other.platformIds, photos: photos, prices: other.prices, user: user, app: app) 38 | } 39 | } 40 | 41 | static func create(id: UUID? = nil, name: String?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel? = nil, app: Application) -> Future { 42 | var instance = Self.init() 43 | instance.id = id 44 | instance.name = name 45 | instance.prices = prices 46 | instance.platformIds = platformIds 47 | return instance.saveIfNeeded(app: app).throwingFlatMap { 48 | try $0.attachPhotos(photos, app: app).transform(to: instance) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/App/Twinable/PromotionProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 25.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol PromotionProtocol: Twinable where TwinType: PromotionProtocol { 14 | var id: UUID? { get set } 15 | var autoApply: Bool { get set } 16 | var name: String? { get set } 17 | var description: String? { get set } 18 | var promocode: String? { get set } 19 | var impact: PromotionImpact! { get set } 20 | var condition: PromotionCondition! { get set } 21 | 22 | init() 23 | static func create(id: UUID?, autoApply: Bool, name: String, description: String, promocode: String?, impact: PromotionImpact, condition: PromotionCondition, app: Application) -> Future 24 | } 25 | 26 | extension PromotionProtocol { 27 | static func create(other: TwinType, app: Application) throws -> Future { 28 | guard let description = other.description, let name = other.name else { 29 | throw ModeledTypeError.validationError(self) 30 | } 31 | return Self.create(id: other.id, autoApply: other.autoApply, name: name, description: description, promocode: other.promocode, impact: other.impact, condition: other.condition, app: app) 32 | } 33 | 34 | static func create(id: UUID? = nil, autoApply: Bool = false, name: String, description: String, promocode: String? = nil, impact: PromotionImpact, condition: PromotionCondition, app: Application) -> Future { 35 | let instance = Self.init() 36 | instance.id = id 37 | instance.autoApply = autoApply 38 | instance.name = name 39 | instance.description = description 40 | instance.promocode = promocode 41 | instance.condition = condition 42 | instance.impact = impact 43 | return instance.saveIfNeeded(app: app) 44 | } 45 | } 46 | 47 | extension Array where Element: PromotionProtocol { 48 | func applying(to price: Float) -> Float { 49 | reduce(Float(price)) { $1.impact.applying(to: $0) } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Future+throwingFlatMap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 15.02.2021. 6 | // 7 | 8 | import Botter 9 | import Vapor 10 | 11 | extension Future where Value: Sequence { 12 | public func throwingFlatMapEach( 13 | on eventLoop: EventLoop, 14 | _ transform: @escaping (_ element: Value.Element) throws -> EventLoopFuture 15 | ) -> EventLoopFuture<[Result]> { 16 | self.throwingFlatMap { .reduce(into: [], try $0.map(transform), on: eventLoop) { $0.append($1) } } 17 | } 18 | } 19 | 20 | extension Future { 21 | public func throwingFlatMap(_ transform: @escaping (Value) throws -> Future) -> Future { 22 | flatMap { value in 23 | do { 24 | return try transform(value) 25 | } catch { 26 | return self.eventLoop.makeFailedFuture(error) 27 | } 28 | } 29 | } 30 | 31 | public func optionalThrowingFlatMap( 32 | _ closure: @escaping (_ unwrapped: Wrapped) throws -> Future 33 | ) -> Future where Value == Optional { 34 | return self.flatMap { optional in 35 | do { 36 | guard let future = try optional.map(closure) else { 37 | return self.eventLoop.makeSucceededFuture(nil) 38 | } 39 | 40 | return future.map(Optional.init) 41 | } catch { 42 | return self.eventLoop.makeFailedFuture(error) 43 | } 44 | } 45 | } 46 | 47 | public func optionalThrowingFlatMap( 48 | _ closure: @escaping (_ unwrapped: Wrapped) throws -> Future 49 | ) -> Future where Value == Optional { 50 | return self.flatMap { optional in 51 | do { 52 | return try optional.flatMap(closure)?.map { $0 } ?? self.eventLoop.makeSucceededFuture(nil) 53 | } catch { 54 | return self.eventLoop.makeFailedFuture(error) 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /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 | # Start database: docker-compose up db 14 | # Run migrations: docker-compose run migrate 15 | # Stop all: docker-compose down (add -v to wipe db) 16 | # 17 | version: '3.7' 18 | 19 | volumes: 20 | db_data: 21 | 22 | x-shared_environment: &shared_environment 23 | LOG_LEVEL: ${LOG_LEVEL:-debug} 24 | DATABASE_HOST: db 25 | DATABASE_NAME: vapor_database 26 | DATABASE_USERNAME: vapor_username 27 | DATABASE_PASSWORD: vapor_password 28 | 29 | services: 30 | app: 31 | image: photobot:latest 32 | build: 33 | context: . 34 | environment: 35 | <<: *shared_environment 36 | depends_on: 37 | - db 38 | ports: 39 | - '80:80' 40 | # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. 41 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "80"] 42 | migrate: 43 | image: photobot:latest 44 | build: 45 | context: . 46 | environment: 47 | <<: *shared_environment 48 | depends_on: 49 | - db 50 | command: ["migrate", "--yes"] 51 | deploy: 52 | replicas: 0 53 | revert: 54 | image: photobot:latest 55 | build: 56 | context: . 57 | environment: 58 | <<: *shared_environment 59 | depends_on: 60 | - db 61 | command: ["migrate", "--revert", "--yes"] 62 | deploy: 63 | replicas: 0 64 | db: 65 | image: postgres:12-alpine 66 | volumes: 67 | - db_data:/var/lib/postgresql/data/pgdata 68 | environment: 69 | PGDATA: /var/lib/postgresql/data/pgdata 70 | POSTGRES_USER: vapor_username 71 | POSTGRES_PASSWORD: vapor_password 72 | POSTGRES_DB: vapor_database 73 | ports: 74 | - '5432:5432' 75 | -------------------------------------------------------------------------------- /Sources/App/Types/PromotionImpact.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 04.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | enum PromotionImpact { 11 | case fixed(Int) 12 | case percents(Int) 13 | } 14 | 15 | extension PromotionImpact { 16 | private func value(for num: Float) -> Float { 17 | let result: Float 18 | switch self { 19 | case let .fixed(fixed): 20 | result = .init(fixed) 21 | case let .percents(percents): 22 | result = num / 100 * Float(percents) 23 | } 24 | return max(result, 0) 25 | } 26 | 27 | func applying(to num: Float) -> Float { 28 | num - value(for: num) 29 | } 30 | 31 | func description(for num: Float) -> String { 32 | "- \(value(for: num)) ₽" 33 | } 34 | } 35 | 36 | extension PromotionImpact: Codable { 37 | 38 | enum CodingKeys: String, CodingKey { 39 | case fixed 40 | case percents 41 | } 42 | 43 | internal init(from decoder: Decoder) throws { 44 | let container = try decoder.container(keyedBy: CodingKeys.self) 45 | 46 | if container.allKeys.contains(.fixed), try container.decodeNil(forKey: .fixed) == false { 47 | let associatedValue0 = try container.decode(Int.self, forKey: .fixed) 48 | self = .fixed(associatedValue0) 49 | return 50 | } 51 | if container.allKeys.contains(.percents), try container.decodeNil(forKey: .percents) == false { 52 | let associatedValue0 = try container.decode(Int.self, forKey: .percents) 53 | self = .percents(associatedValue0) 54 | return 55 | } 56 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown enum case")) 57 | } 58 | 59 | internal func encode(to encoder: Encoder) throws { 60 | var container = encoder.container(keyedBy: CodingKeys.self) 61 | 62 | switch self { 63 | case let .fixed(associatedValue0): 64 | try container.encode(associatedValue0, forKey: .fixed) 65 | case let .percents(associatedValue0): 66 | try container.encode(associatedValue0, forKey: .percents) 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "photobot", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | dependencies: [ 10 | // 💧 A server-side Swift web framework. 11 | .package(url: "https://github.com/vapor/vapor.git", from: "4.77.1"), 12 | .package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"), 13 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"), 14 | .package(url: "https://github.com/CoolONEOfficial/Botter.git", .branch("main")), 15 | .package(url: "https://github.com/SvenTiigi/ValidatedPropertyKit.git", .exact("0.0.4")), 16 | .package(url: "https://github.com/CoolONEOfficial/SwiftyChrono.git", .branch("master")), 17 | .package(url: "https://github.com/CoolONEOfficial/DateHelper.git", .branch("master")), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "App", 22 | dependencies: [ 23 | .product(name: "Fluent", package: "fluent"), 24 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), 25 | .product(name: "Vapor", package: "vapor"), 26 | .product(name: "Botter", package: "Botter"), 27 | .product(name: "ValidatedPropertyKit", package: "ValidatedPropertyKit"), 28 | .product(name: "DateHelper", package: "DateHelper"), 29 | .product(name: "SwiftyChrono", package: "SwiftyChrono"), 30 | ], 31 | swiftSettings: [ 32 | // Enable better optimizations when building in Release configuration. Despite the use of 33 | // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release 34 | // builds. See for details. 35 | .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) 36 | ] 37 | ), 38 | .target(name: "Run", dependencies: [.target(name: "App")]), 39 | .testTarget(name: "AppTests", dependencies: [ 40 | .target(name: "App"), 41 | .product(name: "XCTVapor", package: "vapor"), 42 | ]) 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /Sources/App/Generated/AutoCodable.generated.swift: -------------------------------------------------------------------------------- 1 | // Generated using Sourcery 1.0.2 — https://github.com/krzysztofzablocki/Sourcery 2 | // DO NOT EDIT 3 | 4 | import Vapor 5 | import AnyCodable 6 | 7 | extension NodeAction.Action { 8 | 9 | enum CodingKeys: String, CodingKey { 10 | case push 11 | //case moveToBuilder 12 | case pop 13 | case target 14 | case of 15 | } 16 | 17 | internal init(from decoder: Decoder) throws { 18 | let container = try decoder.container(keyedBy: CodingKeys.self) 19 | 20 | if container.allKeys.contains(.push), try container.decodeNil(forKey: .push) == false { 21 | let associatedValues = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .push) 22 | let target = try associatedValues.decode(PushTarget.self, forKey: .target) 23 | self = .push(target: target) 24 | return 25 | } 26 | // if container.allKeys.contains(.moveToBuilder), try container.decodeNil(forKey: .moveToBuilder) == false { 27 | // let associatedValues = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .moveToBuilder) 28 | // let of = try associatedValues.decode(BuildableType.self, forKey: .of) 29 | // self = .moveToBuilder(of: of) 30 | // return 31 | // } 32 | if container.allKeys.contains(.pop), try container.decodeNil(forKey: .pop) == false { 33 | self = .pop 34 | return 35 | } 36 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown enum case")) 37 | } 38 | 39 | internal func encode(to encoder: Encoder) throws { 40 | var container = encoder.container(keyedBy: CodingKeys.self) 41 | 42 | switch self { 43 | case let .push(target): 44 | var associatedValues = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .push) 45 | try associatedValues.encode(target, forKey: .target) 46 | // case let .moveToBuilder(of): 47 | // var associatedValues = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .moveToBuilder) 48 | // try associatedValues.encode(of, forKey: .of) 49 | case .pop: 50 | _ = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .pop) 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Sources/App/Twinable/MakeuperProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 27.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol MakeuperProtocol: PhotosProtocol, UsersProtocol, PlatformIdentifiable, Priceable, Twinable where TwinType: MakeuperProtocol { 14 | 15 | associatedtype ImplementingModel = MakeuperModel 16 | associatedtype SiblingModel = MakeuperPhoto 17 | 18 | var id: UUID? { get set } 19 | var name: String? { get set } 20 | var user: UserModel! { get set } 21 | 22 | init() 23 | static func create(id: UUID?, name: String?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel?, app: Application) -> Future 24 | } 25 | 26 | fileprivate enum MakeuperCreateError: Error { 27 | case noUser 28 | } 29 | 30 | extension MakeuperProtocol { 31 | static func create(other: TwinType, app: Application) throws -> Future { 32 | [ 33 | other.getUser(app: app).map { $0 as Any }, 34 | other.getPhotos(app: app).map { $0 as Any }, 35 | ].flatten(on: app.eventLoopGroup.next()).flatMap { 36 | let (user, photos) = ($0[0] as? UserModel, $0[1] as? [PlatformFileModel]) 37 | return Self.create(id: other.id, name: other.name, platformIds: other.platformIds, photos: photos, prices: other.prices, user: user, app: app) 38 | } 39 | } 40 | 41 | static func create(id: UUID? = nil, name: String?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel? = nil, app: Application) -> Future { 42 | var instance = Self.init() 43 | instance.id = id 44 | instance.name = name 45 | instance.platformIds = platformIds 46 | instance.prices = prices 47 | return instance.saveIfNeeded(app: app).throwingFlatMap { 48 | var futures = [ 49 | try $0.attachPhotos(photos, app: app), 50 | ] 51 | 52 | if let user = user { 53 | futures.append(try $0.attachUser(user, app: app)) 54 | } 55 | 56 | return futures 57 | .flatten(on: app.eventLoopGroup.next()) 58 | .transform(to: instance) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Vkontakter logo

4 | 5 | # PhotoBot 6 | 7 | ### Unleash your photography potential with PhotoBot! An efficient chatbot designed for photographers and enthusiasts alike. Streamline orders, showcase your portfolio, read reviews, and more. Powered by the [Botter](https://github.com/CoolONEOfficial/botter) framework, PhotoBot enhances your photographic journey. 8 | 9 | [![Language](https://img.shields.io/badge/language-Swift%205.1-orange.svg)](https://swift.org/download/) 10 | [![Platform](https://img.shields.io/badge/platform-Linux%20/%20macOS-ffc713.svg)](https://swift.org/download/) 11 | [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://github.com/CoolONEOfficial/Vkontakter/blob/master/LICENSE) 12 | 13 |

MacStadium logo

14 | 15 | ## Get started 16 | 17 | ### Create .env or .env.development with: 18 | 19 | ```env 20 | DATABASE_URL=postgresql://USERNAME@localhost/DB_NAME 21 | TG_BOT_TOKEN=... 22 | VK_GROUP_TOKEN=... 23 | VK_ADMIN_NICKNAME=... 24 | TG_ADMIN_NICKNAME=... 25 | VK_BUFFER_USER_ID=... 26 | TG_BUFFER_USER_ID=... 27 | VK_GROUP_ID=... (Optional) 28 | VK_NEW_SERVER_NAME=... (Optional) 29 | TARGET_PLATFORM=... (Optional) 30 | VK_PORT and TG_PORT or PORT=... (Optional) 31 | WEBHOOKS_URL=... (Optional) 32 | ``` 33 | 34 | Documentation 35 | --------------- 36 | 37 | - Read [our wiki](https://github.com/CoolONEOfficial/PhotoBot/wiki) 38 | - Read [Botter documentation](https://github.com/CoolONEOfficial/Botter) 39 | - Read [An official documentation of Vapor](https://docs.vapor.codes/4.0/) 40 | 41 | Requirements 42 | --------------- 43 | 44 | - Ubuntu 16.04 or later with [Swift 5.1 or later](https://swift.org/getting-started/) / macOS with [Xcode 11 or later](https://swift.org/download/) 45 | - Vk account and a Vk App for mobile platform or online (desktop client does not support some chatbot features) 46 | - [Swift Package Manager (SPM)](https://github.com/apple/swift-package-manager/blob/master/Documentation/Usage.md) for dependencies 47 | - [Vapor 4](https://vapor.codes) 48 | 49 | Contributing 50 | --------------- 51 | 52 | See [CONTRIBUTING.md](CONTRIBUTING.md) file. 53 | 54 | Author 55 | --------------- 56 | 57 | Nikolai Trukhin 58 | 59 | [coolone.official@gmail.com](mailto:coolone.official@gmail.com) 60 | [@cooloneofficial](https://t.me/cooloneofficial) 61 | 62 | -------------------------------------------------------------------------------- /Sources/App/Twinable/PhotographerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 24.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol PhotographerProtocol: PhotosProtocol, UsersProtocol, PlatformIdentifiable, Priceable, Twinable where TwinType: PhotographerProtocol { 14 | 15 | associatedtype ImplementingModel = PhotographerModel 16 | associatedtype SiblingModel = PhotographerPhoto 17 | 18 | var id: UUID? { get set } 19 | var name: String? { get set } 20 | var user: UserModel! { get set } 21 | 22 | init() 23 | static func create(id: UUID?, name: String?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel?, app: Application) -> Future 24 | } 25 | 26 | fileprivate enum PhotographerCreateError: Error { 27 | case noUser 28 | } 29 | 30 | extension PhotographerProtocol { 31 | static func create(other: TwinType, app: Application) throws -> Future { 32 | [ 33 | other.getUser(app: app).map { $0 as Any }, 34 | other.getPhotos(app: app).map { $0 as Any }, 35 | ].flatten(on: app.eventLoopGroup.next()).flatMap { 36 | let (user, photos) = ($0[0] as? UserModel, $0[1] as? [PlatformFileModel]) 37 | return Self.create(id: other.id, name: other.name, platformIds: other.platformIds, photos: photos, prices: other.prices, user: user, app: app) 38 | } 39 | } 40 | 41 | static func create(id: UUID? = nil, name: String?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel? = nil, app: Application) -> Future { 42 | var instance = Self.init() 43 | instance.id = id 44 | instance.name = name 45 | instance.platformIds = platformIds 46 | instance.prices = prices 47 | return instance.saveIfNeeded(app: app).throwingFlatMap { 48 | var futures = [ 49 | try $0.attachPhotos(photos, app: app), 50 | ] 51 | 52 | if let user = user { 53 | futures.append(try $0.attachUser(user, app: app)) 54 | } 55 | 56 | return futures 57 | .flatten(on: app.eventLoopGroup.next()) 58 | .transform(to: instance) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/App/Twinable/StudioProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 27.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | protocol StudioProtocol: PhotosProtocol, Priceable, PlatformIdentifiable, UsersProtocol, Twinable where TwinType: StudioProtocol { 14 | var id: UUID? { get set } 15 | var name: String? { get set } 16 | var description: String? { get set } 17 | var address: String? { get set } 18 | var coords: Coords? { get set } 19 | 20 | init() 21 | static func create(id: UUID?, name: String?, description: String?, address: String?, coords: Coords?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel?, app: Application) -> Future 22 | } 23 | 24 | extension StudioProtocol { 25 | var photosSiblings: AttachableFileSiblings? { nil } 26 | 27 | static func create(other: TwinType, app: Application) throws -> Future { 28 | other.getPhotos(app: app).flatMap { photos in 29 | Self.create(id: other.id, name: other.name, description: other.description, address: other.address, coords: other.coords, platformIds: other.platformIds, photos: photos, prices: other.prices, app: app) 30 | } 31 | } 32 | 33 | static func create(id: UUID? = nil, name: String?, description: String?, address: String?, coords: Coords?, platformIds: [TypedPlatform], photos: [PlatformFileModel]?, prices: [OrderType: Float], user: UserModel? = nil, app: Application) -> Future { 34 | var instance = Self.init() 35 | instance.id = id 36 | instance.name = name 37 | instance.description = description 38 | instance.address = address 39 | instance.coords = coords 40 | instance.platformIds = platformIds 41 | instance.prices = prices 42 | return instance.saveIfNeeded(app: app).throwingFlatMap { 43 | var futures = [ 44 | try $0.attachPhotos(photos, app: app), 45 | ] 46 | 47 | if let user = user { 48 | futures.append(try $0.attachUser(user, app: app)) 49 | } 50 | 51 | return futures 52 | .flatten(on: app.eventLoopGroup.next()) 53 | .transform(to: instance) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift: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 | COPY ./Package.* ./ 20 | RUN swift package resolve 21 | 22 | # Copy entire repo into container 23 | COPY . . 24 | 25 | # Build everything, with optimizations and test discovery 26 | RUN swift build --enable-test-discovery -c release 27 | 28 | # Switch to the staging area 29 | WORKDIR /staging 30 | 31 | # Copy main executable to staging area 32 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./ 33 | 34 | # Copy any resouces from the public directory and views directory if the directories exist 35 | # Ensure that by default, neither the directory nor any of its contents are writable. 36 | RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true 37 | RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true 38 | 39 | # ================================ 40 | # Run image 41 | # ================================ 42 | FROM swift:5.3-focal-slim 43 | 44 | # Make sure all system packages are up to date. 45 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true && \ 46 | apt-get -q update && apt-get -q dist-upgrade -y && rm -r /var/lib/apt/lists/* 47 | 48 | # Create a vapor user and group with /app as its home directory 49 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor 50 | 51 | # Switch to the new home directory 52 | WORKDIR /app 53 | 54 | # Copy built executable and any staged resources from builder 55 | COPY --from=build --chown=vapor:vapor /staging /app 56 | 57 | # Ensure all further commands run as the vapor user 58 | USER vapor:vapor 59 | 60 | # Let Docker bind to port 80 61 | EXPOSE 80 62 | 63 | # Start the Vapor service when the image is run, default to listening on 80 in production environment 64 | ENTRYPOINT ["./Run"] 65 | CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "80"] 66 | -------------------------------------------------------------------------------- /Sources/App/Twinable/NodeProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 26.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | public enum EntryPoint: String, Codable, CaseIterable { 14 | case welcome 15 | case welcomeGuest 16 | case showcase 17 | case orderTypes 18 | case orderReplacement 19 | case orderBuilder 20 | case orderBuilderStylist 21 | case orderBuilderStudio 22 | case orderBuilderMakeuper 23 | case orderBuilderPhotographer 24 | case orderBuilderDate 25 | case orderCheckout 26 | case about 27 | case portfolio 28 | case orders 29 | case uploadPhoto 30 | case reviews 31 | case messageEdit 32 | case orderAgreement 33 | 34 | static let orderBuildable: [Self] = [ .orderReplacement, .orderBuilder ] 35 | } 36 | 37 | protocol NodeProtocol: Twinable where TwinType: NodeProtocol { 38 | var id: UUID? { get set } 39 | var systemic: Bool { get set } 40 | var name: String? { get set } 41 | var messagesGroup: SendMessageGroup! { get set } 42 | var entryPoint: EntryPoint? { get set } 43 | var action: NodeAction? { get set } 44 | var closeable: Bool { get set } 45 | 46 | init() 47 | static func create(id: UUID?, systemic: Bool, closeable: Bool, name: String?, messagesGroup: SendMessageGroup, entryPoint: EntryPoint?, action: NodeAction?, app: Application) -> Future 48 | } 49 | 50 | enum NodeCreateError: Error { 51 | case noMessageGroup 52 | } 53 | 54 | extension NodeProtocol { 55 | static func create(other: TwinType, app: Application) throws -> Future { 56 | guard let messagesGroup = other.messagesGroup else { throw NodeCreateError.noMessageGroup } 57 | return Self.create(id: other.id, systemic: other.systemic, closeable: other.closeable, name: other.name, messagesGroup: messagesGroup, entryPoint: other.entryPoint, action: other.action, app: app) 58 | } 59 | 60 | static func create(id: UUID? = nil, systemic: Bool = false, closeable: Bool = true, name: String?, messagesGroup: SendMessageGroup, entryPoint: EntryPoint? = nil, action: NodeAction? = nil, app: Application) -> Future { 61 | let instance = Self.init() 62 | instance.id = id 63 | instance.systemic = systemic 64 | instance.closeable = closeable 65 | instance.name = name 66 | instance.messagesGroup = messagesGroup 67 | instance.entryPoint = entryPoint 68 | instance.action = action 69 | return instance.saveIfNeeded(app: app) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/App/VkEchoBot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.01.2021. 6 | // 7 | 8 | import Foundation 9 | import Telegrammer 10 | import TelegrammerMiddleware 11 | import Vkontakter 12 | import VkontakterMiddleware 13 | import Botter 14 | import Vapor 15 | 16 | class VkEchoBot { 17 | public let dispatcher: Vkontakter.Dispatcher 18 | public let bot: Vkontakter.Bot 19 | public let updater: Vkontakter.Updater 20 | 21 | public init(settings: Vkontakter.Bot.Settings) throws { 22 | self.bot = try .init(settings: settings) 23 | self.dispatcher = .init(bot: bot) 24 | self.updater = .init(bot: bot, dispatcher: dispatcher) 25 | 26 | dispatcher.add( 27 | handler: Vkontakter.MessageHandler( 28 | filters: .all, 29 | callback: echoResponse 30 | ) 31 | ) 32 | } 33 | 34 | func echoResponse(_ update: Vkontakter.Update, _ context: Vkontakter.BotContext?) throws { 35 | guard case let .messageWrapper(wrapper) = update.object, let text = wrapper.message.text else { 36 | return 37 | } 38 | 39 | var message: String = "Starting.." 40 | 41 | defer { 42 | let params = Vkontakter.Bot.SendMessageParams( 43 | randomId: .random(), 44 | peerId: wrapper.message.fromId!, 45 | message: message 46 | ) 47 | 48 | try! bot.sendMessage(params: params) 49 | } 50 | // let jpgLink = "https://upload.wikimedia.org/wikipedia/ru/a/a9/Example.jpg" 51 | // let txtLink = "https://www.w3.org/TR/PNG/iso_8859-1.txt" 52 | 53 | guard let url = URL(string: text) else { 54 | message = "URL incorrect" 55 | return 56 | } 57 | 58 | guard let data = try? Data(contentsOf: url) else { 59 | message = "Cannot get data" 60 | return 61 | } 62 | 63 | func randomString(length: Int) -> String { 64 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 65 | return String((0.. = [ .photo(.init(id: attachable.mediaId, ownerId: attachable.ownerId)) ] 72 | 73 | try! self.bot.sendMessage(params: .init( 74 | userId: wrapper.message.fromId, 75 | randomId: .random(), 76 | message: String(attachable.mediaId!), 77 | attachment: att 78 | )) 79 | } 80 | 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Bot+sendNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 08.01.2021. 6 | // 7 | 8 | import Botter 9 | import Vapor 10 | 11 | extension Bot { 12 | func sendNode(to replyable: R, node: Node, payload: NodePayload?, platform: AnyPlatform, context: PhotoBotContextProtocol) throws -> Future<[Botter.Message]>? { 13 | try node.messagesGroup?.getSendMessages(platform: replyable.platform.any, in: node, payload, context: context).throwingFlatMap { messages -> Future<[Botter.Message]> in 14 | let (app, user) = (context.app, context.user) 15 | var future: Future<[Botter.Message]> = app.eventLoopGroup.future([]) 16 | 17 | for params in messages { 18 | params.destination = replyable.destination 19 | future = future.flatMap { messages in 20 | params.keyboard.buttons.map { buttons in 21 | buttons.map(\.payload).map { payload -> Future in 22 | guard let payload = payload else { return app.eventLoopGroup.future(nil) } 23 | if payload.count > 64 { 24 | return EventPayloadModel(instance: payload, ownerId: user.id!, nodeId: user.nodeId!) 25 | .saveWithId(on: app.db) 26 | .flatMapThrowing { id in 27 | switch platform { 28 | case .tg: // tg payload is just string like "blahblah" 29 | return String(describing: id) 30 | case .vk: // vk payload is json string like "\"blahblah\"" 31 | return try id.encodeToString()! 32 | } 33 | } 34 | } else { 35 | return app.eventLoopGroup.future(payload) 36 | } 37 | }.flatten(on: app.eventLoopGroup.next()) 38 | }.flatten(on: app.eventLoopGroup.next()).throwingFlatMap { buttonPayloads in 39 | for (index, list) in buttonPayloads.enumerated() { 40 | for (innerIndex, payload) in list.enumerated() { 41 | if let payload = payload { 42 | params.keyboard.buttons[index][innerIndex].payload = payload 43 | } 44 | } 45 | } 46 | 47 | return try self.sendMessage( 48 | params.params!, 49 | platform: platform, 50 | context: context 51 | ).map { messages + $0 } 52 | } 53 | } 54 | } 55 | 56 | return future 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/Builder/OrderBuilderStudioNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderBuilderStudioNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order builder studio node", 16 | messagesGroup: .list(.studios), 17 | entryPoint: .orderBuilderStudio, app: app 18 | ) 19 | } 20 | 21 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> EventLoopFuture<([SendMessage], Int)>? { 22 | guard listType == .studios else { return nil } 23 | let (app, user) = (context.app, context.user) 24 | guard case let .orderBuilder(state) = payload, let orderType = state.type else { throw SendMessageGroupError.invalidPayload } 25 | let model = StudioModel.self 26 | return model.query(on: app.db).count().flatMap { count in 27 | model.query(on: app.db).filter(.sql(raw: "prices ? '\(orderType.rawValue)'")).range(indexRange).all().flatMap { studios in 28 | studios.enumerated().map { (index, studio) -> Future in 29 | studio.$_photos.get(on: app.db).throwingFlatMap { photos -> Future in 30 | try photos.map { try PlatformFile.create(other: $0, app: app) } 31 | .flatten(on: app.eventLoopGroup.next()) 32 | .flatMapThrowing { attachments -> SendMessage in 33 | SendMessage( 34 | text: "\(studio.name ?? "")\n\(studio.prices[orderType]!) ₽ / час", 35 | keyboard: [ [ 36 | try Button( 37 | text: "Выбрать", 38 | action: .callback, 39 | eventPayload: .selectStudio(id: try studio.requireID()) 40 | ) 41 | ] ], 42 | attachments: attachments.compactMap { $0.fileInfo } 43 | ) 44 | 45 | } 46 | } 47 | } 48 | .flatten(on: app.eventLoopGroup.next()) 49 | .map { ($0, count) } 50 | } 51 | } 52 | } 53 | 54 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> EventLoopFuture<[Message]>? { 55 | guard case let .selectStudio(studioId) = eventPayload else { return nil } 56 | let (app, user) = (context.app, context.user) 57 | 58 | guard let nodeId = user.history.firstOrderBuildable?.nodeId else { 59 | fatalError() 60 | } 61 | 62 | replyText = "Selected" 63 | return Studio.find(studioId, app: app).throwingFlatMap { studio in 64 | try user.push(.id(nodeId), payload: .orderBuilder(.init(with: user.history.last?.nodePayload, studio: studio)), to: event, saveMove: false, context: context) 65 | } 66 | 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/Builder/OrderBuilderMakeuperNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderBuilderMakeuperNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order builder makeuper node", 16 | messagesGroup: .list(.makeupers), 17 | entryPoint: .orderBuilderMakeuper, app: app 18 | ) 19 | } 20 | 21 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> EventLoopFuture<([SendMessage], Int)>? { 22 | guard listType == .makeupers else { return nil } 23 | let (app, user) = (context.app, context.user) 24 | guard case let .orderBuilder(state) = payload, let orderType = state.type else { throw SendMessageGroupError.invalidPayload } 25 | 26 | return MakeuperModel.query(on: app.db).count().flatMap { count in 27 | MakeuperModel.query(on: app.db).filter(.sql(raw: "prices ? '\(orderType.rawValue)'")).range(indexRange).all().flatMap { humans in 28 | humans.enumerated().map { (index, human) -> Future in 29 | human.$_photos.get(on: app.db).throwingFlatMap { photos -> Future in 30 | try photos.map { try PlatformFile.create(other: $0, app: app) } 31 | .flatten(on: app.eventLoopGroup.next()) 32 | .flatMapThrowing { attachments -> SendMessage in 33 | SendMessage( 34 | text: "\(human.name ?? "")\n\(human.prices[orderType]!) ₽ / час\n\(human.platformLink(for: platform) ?? "")", 35 | keyboard: [ [ 36 | try Button( 37 | text: "Выбрать", 38 | action: .callback, 39 | eventPayload: .selectMakeuper(id: try human.requireID()) 40 | ) 41 | ] ], 42 | attachments: attachments.compactMap { $0.fileInfo } 43 | ) 44 | 45 | } 46 | } 47 | } 48 | .flatten(on: app.eventLoopGroup.next()) 49 | .map { ($0, count) } 50 | } 51 | } 52 | } 53 | 54 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> EventLoopFuture<[Message]>? { 55 | guard case let .selectMakeuper(makeuperId) = eventPayload else { return nil } 56 | let (app, user) = (context.app, context.user) 57 | 58 | guard let nodeId = user.history.firstOrderBuildable?.nodeId else { 59 | fatalError() 60 | } 61 | 62 | replyText = "Selected" 63 | return Makeuper.find(makeuperId, app: app).throwingFlatMap { makeuper in 64 | try user.push(.id(nodeId), payload: .orderBuilder(.init(with: user.history.last?.nodePayload, makeuper: makeuper)), to: event, saveMove: false, context: context) 65 | } 66 | 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/Builder/OrderBuilderStylistNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderBuilderStylistNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order builder stylist node", 16 | messagesGroup: .list(.stylists), 17 | entryPoint: .orderBuilderStylist, app: app 18 | ) 19 | } 20 | 21 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> EventLoopFuture<([SendMessage], Int)>? { 22 | guard listType == .stylists else { return nil } 23 | let (app, user) = (context.app, context.user) 24 | guard case let .orderBuilder(state) = payload, let orderType = state.type else { throw SendMessageGroupError.invalidPayload } 25 | let model = StylistModel.self 26 | return model.query(on: app.db).count().flatMap { count in 27 | model.query(on: app.db).filter(.sql(raw: "prices ? '\(orderType.rawValue)'")).range(indexRange).all().flatMap { humans in 28 | humans.enumerated().map { (index, human) -> Future in 29 | human.$_photos.get(on: app.db).throwingFlatMap { photos -> Future in 30 | try photos.map { try PlatformFile.create(other: $0, app: app) } 31 | .flatten(on: app.eventLoopGroup.next()) 32 | .flatMapThrowing { attachments -> SendMessage in 33 | SendMessage( 34 | text: "\(human.name ?? "")\n\(human.prices[orderType]!) ₽ / час\n\(human.platformLink(for: platform) ?? "")", 35 | keyboard: [ [ 36 | try Button( 37 | text: "Выбрать", 38 | action: .callback, 39 | eventPayload: .selectStylist(id: try human.requireID()) 40 | ) 41 | ] ], 42 | attachments: attachments.compactMap { $0.fileInfo } 43 | ) 44 | 45 | } 46 | } 47 | } 48 | .flatten(on: app.eventLoopGroup.next()) 49 | .map { ($0, count) } 50 | } 51 | } 52 | } 53 | 54 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> EventLoopFuture<[Message]>? { 55 | guard case let .selectStylist(stylistId) = eventPayload else { return nil } 56 | let (app, user) = (context.app, context.user) 57 | 58 | guard let nodeId = user.history.firstOrderBuildable?.nodeId else { 59 | fatalError() 60 | } 61 | 62 | replyText = "Selected" 63 | return Stylist.find(stylistId, app: app).throwingFlatMap { stylist in 64 | try user.push(.id(nodeId), payload: .orderBuilder(.init(with: user.history.last?.nodePayload, stylist: stylist)), to: event, saveMove: false, context: context) 65 | } 66 | 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/App/Modules/Systemic/ChangeTextNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class ChangeTextNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | systemic: true, 16 | name: "Change static node text node", 17 | messagesGroup: .editNodeText, 18 | entryPoint: .messageEdit, 19 | action: .init(.messageEdit, success: .pop), 20 | app: app 21 | ) 22 | } 23 | 24 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> EventLoopFuture<[SendMessage]>? { 25 | guard case .editNodeText = group else { return nil } 26 | 27 | let (app, user) = (context.app, context.user) 28 | 29 | return Node.find(.id(user.history.last!.nodeId), app: app).flatMapThrowing { node in 30 | guard let nodePayload = user.nodePayload, 31 | case let .editText(messageId) = nodePayload, 32 | case let .array(messages) = node.messagesGroup else { 33 | throw HandleActionError.nodePayloadInvalid 34 | } 35 | let message = messages[messageId] 36 | 37 | return [ 38 | .init(text: "Актуальный текст ноды:"), 39 | .init(text: message.text, formatText: false), 40 | .init(text: "Пришли мне новый текст"), 41 | ] 42 | } 43 | } 44 | 45 | func handleAction(_ action: NodeAction, _ message: Message, context: PhotoBotContextProtocol) throws -> EventLoopFuture>? { 46 | guard case .messageEdit = action.type, let text = message.text else { return nil } 47 | 48 | let (app, user) = (context.app, context.user) 49 | 50 | return Node.find(.id(user.history.last!.nodeId), app: app).throwingFlatMap { node in 51 | 52 | guard let nodePayload = user.nodePayload, 53 | case let .editText(messageId) = nodePayload else { 54 | throw HandleActionError.nodePayloadInvalid 55 | } 56 | 57 | node.messagesGroup?.updateText(at: messageId, text: text) 58 | 59 | return try node.save(app: app) 60 | .throwingFlatMap { _ in try message.reply(.init(text: "Текст успешно изменен."), context: context) } 61 | .map { _ in .success } 62 | } 63 | } 64 | 65 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> EventLoopFuture<[Message]>? { 66 | guard case let .editText(messageId) = eventPayload else { return nil } 67 | let (app, user) = (context.app, context.user) 68 | 69 | replyText = "Move" 70 | return Node.find(.entryPoint(.messageEdit), app: app).throwingFlatMap { node in 71 | try user.push(node, payload: .editText(messageId: messageId), to: event, context: context) 72 | } 73 | } 74 | } 75 | 76 | extension SendMessageGroup { 77 | mutating func updateText(at index: Int, text: String) { 78 | guard case let .array(arr) = self else { return } 79 | arr[index].text = text 80 | self = .array(arr) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/Builder/OrderBuilderPhotographerNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 12.06.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderBuilderPhotographerNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order builder photographer node", 16 | messagesGroup: .list(.photographers), 17 | entryPoint: .orderBuilderPhotographer, app: app 18 | ) 19 | } 20 | 21 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> EventLoopFuture<([SendMessage], Int)>? { 22 | guard listType == .photographers else { return nil } 23 | let (app, user) = (context.app, context.user) 24 | guard case let .orderBuilder(state) = payload, let orderType = state.type else { throw SendMessageGroupError.invalidPayload } 25 | let model = PhotographerModel.self 26 | return model.query(on: app.db).count().flatMap { count in 27 | model.query(on: app.db).filter(.sql(raw: "prices ? '\(orderType.rawValue)'")).range(indexRange).all().flatMap { humans in 28 | humans.enumerated().map { (index, human) -> Future in 29 | human.$_photos.get(on: app.db).throwingFlatMap { photos -> Future in 30 | try photos.map { try PlatformFile.create(other: $0, app: app) } 31 | .flatten(on: app.eventLoopGroup.next()) 32 | .flatMapThrowing { attachments -> SendMessage in 33 | SendMessage( 34 | text: "\(human.name ?? "")\n\(human.prices[orderType]!) ₽ / час\n\(human.platformLink(for: platform) ?? "")", 35 | keyboard: [ [ 36 | try Button( 37 | text: "Выбрать", 38 | action: .callback, 39 | eventPayload: .selectPhotographer(id: try human.requireID()) 40 | ) 41 | ] ], 42 | attachments: attachments.compactMap { $0.fileInfo } 43 | ) 44 | 45 | } 46 | } 47 | } 48 | .flatten(on: app.eventLoopGroup.next()) 49 | .map { ($0, count) } 50 | } 51 | } 52 | } 53 | 54 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> EventLoopFuture<[Message]>? { 55 | guard case let .selectPhotographer(photographerId) = eventPayload else { return nil } 56 | let (app, user) = (context.app, context.user) 57 | 58 | guard let nodeId = user.history.firstOrderBuildable?.nodeId else { 59 | fatalError() 60 | } 61 | 62 | replyText = "Selected" 63 | return Photographer.find(photographerId, app: app).throwingFlatMap { photographer in 64 | try user.push(.id(nodeId), payload: .orderBuilder(.init(with: user.history.last?.nodePayload, photographer: photographer)), to: event, saveMove: false, context: context) 65 | } 66 | 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/Builder/OrderBuilderMainNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderBuilderMainNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order builder main node", 16 | messagesGroup: .orderBuilder, 17 | entryPoint: .orderBuilder, app: app 18 | ) 19 | } 20 | 21 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> EventLoopFuture<[SendMessage]>? { 22 | guard case .orderBuilder = group else { return nil } 23 | 24 | let app = context.app 25 | 26 | guard case let .orderBuilder(state) = payload, let type = state.type else { 27 | return app.eventLoopGroup.future(error: SendMessageGroupError.invalidPayload) 28 | } 29 | 30 | var keyboard: Keyboard = [[ 31 | try .init(text: "Студия", action: .callback, eventPayload: .push(.entryPoint(.orderBuilderStudio), payload: payload)), 32 | //try .init(text: "Фотограф", action: .callback, eventPayload: .push(.entryPoint(.orderBuilderPhotograph), payload: payload)), 33 | try .init(text: "Дата", action: .callback, eventPayload: .push(.entryPoint(.orderBuilderDate))) 34 | ]] 35 | 36 | switch type { 37 | case .loveStory, .family: 38 | keyboard.buttons[0].insert(contentsOf: [ 39 | try .init(text: "Стилист", action: .callback, eventPayload: .push(.entryPoint(.orderBuilderStylist), payload: payload)), 40 | try .init(text: "Визажист", action: .callback, eventPayload: .push(.entryPoint(.orderBuilderMakeuper), payload: payload)), 41 | ], at: 0) 42 | case .content: break 43 | } 44 | 45 | if state.isValid { 46 | keyboard.buttons.safeAppend([ 47 | try .init(text: "👌 К завершению", action: .callback, eventPayload: .pushCheckout(state: state)) 48 | ]) 49 | } 50 | 51 | return app.eventLoopGroup.future([ .init( 52 | text: [ 53 | "Ваш заказ:", 54 | .replacing(by: .orderBlock), 55 | "Сумма: " + .replacing(by: .price) 56 | ].joined(separator: "\n"), 57 | keyboard: keyboard 58 | ) ]) 59 | } 60 | 61 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> Future<[Botter.Message]>? { 62 | guard case let .pushCheckout(state) = eventPayload else { return nil } 63 | let (app, user) = (context.app, context.user) 64 | 65 | replyText = "Move" 66 | 67 | return PromotionModel.query(on: app.db).filter(\.$autoApply, .equal, true).all().flatMap { promotions -> Future<[Message]> in 68 | promotions.map { promo in 69 | promo.condition.check(state: state, context: context).map { (check: $0, promo: promo) } 70 | } 71 | .flatten(on: app.eventLoopGroup.next()) 72 | .flatMap { promotions -> Future<[Message]> in 73 | let promotions = promotions.filter(\.check).map(\.promo) 74 | return user.push(.entryPoint(.orderCheckout), payload: .checkout(.init(order: state, promotions: promotions)), to: event, saveMove: true, context: context) 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Validation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 07.01.2021. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | import Botter 11 | import AnyCodable 12 | 13 | public extension Validation where Value == String { 14 | /// The Letters Validation 15 | static var isLetters: Validation { 16 | return self.regularExpression("[A-z, А-я]+") 17 | } 18 | 19 | } 20 | 21 | public extension Validation where Value: StringProtocol { 22 | 23 | /// Validation with less `<` than comparable value 24 | /// 25 | /// - Parameter comparableValue: The Comparable value 26 | /// - Returns: The Validation 27 | static func less(_ comparableValue: Int) -> Validation { 28 | return .init { value in 29 | if value.count < comparableValue { 30 | return .success 31 | } else { 32 | return .failure("\(value) is not less than \(comparableValue)") 33 | } 34 | } 35 | } 36 | 37 | /// Validation with less or equal `<=` than comparable value 38 | /// 39 | /// - Parameter comparableValue: The Comparable value 40 | /// - Returns: The Validation 41 | static func lessOrEqual(_ comparableValue: Int) -> Validation { 42 | return .init { value in 43 | if value.count <= comparableValue { 44 | return .success 45 | } else { 46 | return .failure("\(value) is not less or equal than \(comparableValue)") 47 | } 48 | } 49 | } 50 | 51 | /// Validation with greater `>` than comparable value 52 | /// 53 | /// - Parameter comparableValue: The Comparable value 54 | /// - Returns: The Validation 55 | static func greater(_ comparableValue: Int) -> Validation { 56 | return .init { value in 57 | if value.count > comparableValue { 58 | return .success 59 | } else { 60 | return .failure("\(value) is not greater than \(comparableValue)") 61 | } 62 | } 63 | } 64 | 65 | /// Validation with greater or equal `>=` than comparable value 66 | /// 67 | /// - Parameter comparableValue: The Comparable value 68 | /// - Returns: The Validation 69 | static func greaterOrEqual(_ comparableValue: Int) -> Validation { 70 | return .init { value in 71 | if value.count >= comparableValue { 72 | return .success 73 | } else { 74 | return .failure("\(value) is not greater or equal than \(comparableValue)") 75 | } 76 | } 77 | } 78 | } 79 | 80 | extension Validation where Value: Collection { 81 | 82 | /// The nonEmpty Validation 83 | static var nonEmpty: Self { 84 | .init { value in 85 | if !value.isEmpty { 86 | return .success 87 | } else { 88 | return .failure("\(value) is empty") 89 | } 90 | } 91 | } 92 | 93 | } 94 | 95 | extension Validation where Value == [TypedPlatform] { 96 | static func contains(_ platforms: TypedPlatform...) -> Validation { 97 | return contains(platforms) 98 | } 99 | 100 | static func contains(_ platforms: [TypedPlatform]) -> Validation { 101 | return .init { value in 102 | if value.map({ $0.convert(to: AnyCodable()) }).contains(where: { platform in platforms.contains { $0 == platform } }) { 103 | return .success 104 | } else { 105 | return .failure("\(value) is not contains all \(platforms)") 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/App/Modules/Main/UploadPhotoNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Botter 11 | 12 | class UploadPhotoNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Upload photo node", 16 | messagesGroup: [ 17 | .init(text: "Пришли мне ссылки на фото и/или приложи их.") 18 | ], 19 | entryPoint: .uploadPhoto, 20 | action: .init(.uploadPhoto), app: app 21 | ) 22 | } 23 | 24 | func handleAction(_ action: NodeAction, _ message: Message, context: PhotoBotContextProtocol) throws -> EventLoopFuture>? { 25 | guard case .uploadPhoto = action.type else { return nil } 26 | let (app, bot) = (context.app, context.bot) 27 | 28 | let availablePlatforms: [AnyPlatform] = .available(bot: bot) 29 | 30 | return try availablePlatforms.map { platform -> Future<[PlatformFile.Entry]> in 31 | let destination: SendDestination 32 | switch platform { 33 | case .tg: 34 | destination = .chatId(Application.tgBufferUserId) 35 | case .vk: 36 | destination = .userId(Application.vkBufferUserId) 37 | } 38 | 39 | var contentArray = [ 40 | try message.attachments.compactMap { attachment in 41 | try attachment.getUrl(context: context)?.optionalMap { url -> (FileInfo.Content, Attachment?) in 42 | (.url(url), attachment) 43 | } 44 | }.flatten(on: app.eventLoopGroup.next()).map { $0.compactMap { $0 } } 45 | ] 46 | if let text = message.text { 47 | contentArray.append(app.eventLoopGroup.future(text.extractUrls.map { (.url($0.absoluteString), nil) })) 48 | } 49 | 50 | return contentArray.flatten(on: app.eventLoopGroup.next()).map { $0.reduce([], +) }.throwingFlatMap { contentArray in 51 | try contentArray.map { (content, attachment) in 52 | let attachmentFuture: Future 53 | if let attachment = attachment, message.platform.same(platform) { 54 | attachmentFuture = app.eventLoopGroup.future(attachment) 55 | } else { 56 | attachmentFuture = try bot.sendMessage(.init( 57 | destination: destination, 58 | text: "Загружаю вот эту фото", 59 | attachments: [ 60 | .init(type: .photo, content: content) 61 | ] 62 | ), platform: platform, context: context) 63 | .map(\.first?.attachments.first) 64 | .unwrap(orError: HandleActionError.noAttachments) 65 | } 66 | return attachmentFuture.map { attachment in platform.convert(to: attachment.attachmentId) } 67 | }.flatten(on: app.eventLoopGroup.next()) 68 | } 69 | }.flatten(on: app.eventLoopGroup.next()) 70 | .map { $0.reduce([], +) } 71 | .flatMap { platformEntries in 72 | PlatformFile.create(platformEntries: platformEntries, type: .photo, app: app) 73 | .throwingFlatMap { try $0.saveReturningId(app: app) } 74 | .throwingFlatMap { savedId -> Future<[Message]> in 75 | var text = "локальный id: \(savedId)" 76 | text = platformEntries.map { $0.name + ": " + $0.description + "\n" }.joined() + text 77 | return try message.reply(.init(text: text), context: context) 78 | } 79 | }.map { _ in .success } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/App/Models/OrderModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | import Vapor 11 | import Botter 12 | import ValidatedPropertyKit 13 | 14 | final class OrderModel: Model, OrderProtocol { 15 | typealias TwinType = Order 16 | 17 | static let schema = "orders" 18 | 19 | @ID(key: .id) 20 | var id: UUID? 21 | 22 | @Parent(key: "user_id") 23 | var user: UserModel 24 | 25 | var userId: UUID! { 26 | get { self.$user.id } 27 | set { self.$user.id = newValue } 28 | } 29 | 30 | @Field(key: "type") 31 | var type: OrderType! 32 | 33 | @Field(key: "status") 34 | var status: OrderStatus 35 | 36 | @OptionalParent(key: "stylist_id") 37 | var stylist: StylistModel? 38 | 39 | var stylistId: UUID? { 40 | get { self.$stylist.id } 41 | set { self.$stylist.id = newValue } 42 | } 43 | 44 | @OptionalParent(key: "photographer_id") 45 | var photographer: PhotographerModel? 46 | 47 | var photographerId: UUID? { 48 | get { self.$photographer.id } 49 | set { self.$photographer.id = newValue } 50 | } 51 | 52 | @OptionalParent(key: "makeuper_id") 53 | var makeuper: MakeuperModel? 54 | 55 | var makeuperId: UUID? { 56 | get { self.$makeuper.id } 57 | set { self.$makeuper.id = newValue } 58 | } 59 | 60 | @OptionalParent(key: "studio_id") 61 | var studio: StudioModel? 62 | 63 | var studioId: UUID? { 64 | get { self.$studio.id } 65 | set { self.$studio.id = newValue } 66 | } 67 | 68 | @Field(key: "start_date") 69 | var startDate: Date 70 | 71 | @Field(key: "end_date") 72 | var endDate: Date 73 | 74 | var interval: DateInterval { 75 | get { 76 | .init(start: startDate, end: endDate) 77 | } 78 | set { 79 | startDate = newValue.start 80 | endDate = newValue.end 81 | } 82 | } 83 | 84 | @Field(key: "hour_price") 85 | var hourPrice: Float 86 | 87 | @Siblings(through: OrderPromotion.self, from: \.$order, to: \.$promotion) 88 | var _promotions: [PromotionModel] 89 | 90 | var promotions: [PromotionModel] { 91 | get { _promotions } 92 | set { fatalError("Siblings must be attached manually") } 93 | } 94 | 95 | var promotionsSiblings: AttachablePromotionSiblings? { $_promotions } 96 | 97 | @Siblings(through: AgreementModel.self, from: \.$order, to: \.$approver) 98 | var agreements: [UserModel] 99 | 100 | required init() { } 101 | } 102 | 103 | extension OrderModel { 104 | func fetchWatchers(app: Application) -> Future<[PlatformIdentifiable]> { 105 | [ 106 | $makeuper.get(on: app.db).optionalMap { $0 as PlatformIdentifiable }, 107 | $stylist.get(on: app.db).optionalMap { $0 as PlatformIdentifiable }, 108 | $photographer.get(on: app.db).optionalMap { $0 as PlatformIdentifiable }, 109 | $studio.get(on: app.db).optionalMap { $0 as PlatformIdentifiable }, 110 | ].flatten(on: app.eventLoopGroup.next()).map { $0.compactMap { $0 } } 111 | } 112 | 113 | func fetchWatchersUsers(app: Application) -> Future<[UserModel]> { 114 | fetchWatchers(app: app) 115 | .throwingFlatMapEach(on: app.eventLoopGroup.next()) { try $0.getPlatformUser(app: app) } 116 | .map { $0.compactMap { $0 } } 117 | // [ 118 | // $makeuper.get(on: app.db).optionalFlatMap { $0.getUser(app: app) }, 119 | // $stylist.get(on: app.db).optionalFlatMap { $0.getUser(app: app) }, 120 | // $photographer.get(on: app.db).optionalFlatMap { $0.getUser(app: app) }, 121 | // $studio.get(on: app.db).optionalFlatMap { $0.getUser(app: app) }, 122 | // ].flatten(on: app.eventLoopGroup.next()).map { $0.compactMap { $0 } } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/App/Models/UserModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserModel.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 08.01.2021. 6 | // 7 | 8 | import Fluent 9 | import Vapor 10 | import Botter 11 | import ValidatedPropertyKit 12 | 13 | final class UserModel: Model, UserProtocol { 14 | typealias TwinType = User 15 | 16 | static let schema = "users" 17 | 18 | @ID(key: .id) 19 | var id: UUID? 20 | 21 | @Field(key: "history") 22 | var history: [UserHistoryEntry] 23 | 24 | @OptionalParent(key: "node_id") 25 | var node: NodeModel? 26 | 27 | var nodeId: UUID? { 28 | get { self.$node.id } 29 | set { self.$node.id = newValue } 30 | } 31 | 32 | @OptionalField(key: "node_payload") 33 | var nodePayload: NodePayload? 34 | 35 | @Field(key: "platform_ids") 36 | var platformIds: [TypedPlatform] 37 | 38 | @Field(key: "is_admin") 39 | var isAdmin: Bool 40 | 41 | @OptionalField(key: "first_name") 42 | var firstName: String? 43 | 44 | @OptionalField(key: "last_name") 45 | var lastName: String? 46 | 47 | @OptionalParent(key: "makeuper_id") 48 | var _makeuper: MakeuperModel? 49 | 50 | var makeuper: MakeuperModel? { 51 | get { _makeuper } 52 | set { $_makeuper.id = newValue?.id } 53 | } 54 | 55 | var makeuperId: UUID? { $_makeuper.id } 56 | 57 | @OptionalParent(key: "stylist_id") 58 | var _stylist: StylistModel? 59 | 60 | var stylist: StylistModel? { 61 | get { _stylist } 62 | set { $_stylist.id = newValue?.id } 63 | } 64 | 65 | var stylistId: UUID? { $_stylist.id } 66 | 67 | @OptionalParent(key: "photographer_id") 68 | var _photographer: PhotographerModel? 69 | 70 | var photographer: PhotographerModel? { 71 | get { _photographer } 72 | set { $_photographer.id = newValue?.id } 73 | } 74 | 75 | var photographerId: UUID? { $_photographer.id } 76 | 77 | @OptionalParent(key: "studio_id") 78 | var _studio: StudioModel? 79 | 80 | var studio: StudioModel? { 81 | get { _studio } 82 | set { $_studio.id = newValue?.id } 83 | } 84 | 85 | var studioId: UUID? { $_studio.id } 86 | 87 | @Children(for: \.$owner) 88 | var payloads: [EventPayloadModel] 89 | 90 | @OptionalField(key: "last_destination") 91 | var lastDestination: UserDestination? 92 | 93 | required init() { } 94 | 95 | } 96 | 97 | extension UserModel { 98 | private static func filterQuery(_ platform: AnyPlatform, _ field: String, _ value: T) throws -> String { 99 | """ 100 | EXISTS ( 101 | SELECT FROM unnest(platform_ids) AS elem WHERE (to_jsonb(elem)::json#>'{\(platform.name), \(field)}')::text = '\(try value.encodeToString()!)' 102 | ) 103 | """ 104 | } 105 | 106 | public static func find( 107 | _ platformReplyable: T, 108 | on database: Database 109 | ) throws -> Future { 110 | guard let destination = platformReplyable.destination else { throw PhotoBotError.destinationNotFound } 111 | let platform = platformReplyable.platform.any 112 | return try Self.find(destination: destination, platform: platform, on: database) 113 | } 114 | 115 | public static func find( 116 | destination: SendDestination, 117 | platform: AnyPlatform, 118 | on database: Database 119 | ) throws -> Future { 120 | let filterQuery: String 121 | 122 | switch destination { 123 | case let .chatId(id), let .userId(id): 124 | filterQuery = try UserModel.filterQuery(platform, "id", id) 125 | 126 | case let .username(username): 127 | filterQuery = try UserModel.filterQuery(platform, "username", username) 128 | } 129 | 130 | return query(on: database).filter(.sql(raw: filterQuery)).first() 131 | } 132 | } 133 | 134 | extension Array { 135 | func first(platform: AnyPlatform) -> Element? where Element == Platform { 136 | first { $0.any == platform } 137 | } 138 | 139 | func firstValue(platform: AnyPlatform) -> T? where Element == TypedPlatform { 140 | first(platform: platform)?.value 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/App/EchoBot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 22.11.2020. 6 | // 7 | 8 | import Foundation 9 | import Telegrammer 10 | import TelegrammerMiddleware 11 | import Vkontakter 12 | import VkontakterMiddleware 13 | import Botter 14 | import Vapor 15 | // 16 | //class EchoBot { 17 | // public let dispatcher: Botter.Dispatcher 18 | // public let bot: Botter.Bot 19 | // public let updater: Botter.Updater 20 | // public let app: Application 21 | // 22 | // public init(settings: Botter.Bot.Settings, app: Application) throws { 23 | // self.bot = try .init(settings: settings) 24 | // self.dispatcher = .init(bot: bot) 25 | // self.updater = .init(bot: bot, dispatcher: dispatcher) 26 | // self.app = app 27 | // 28 | // dispatcher.add(handler: Botter.MessageHandler(filters: .all, callback: handleMessage)) 29 | // 30 | //// dispatcher.add( 31 | //// handler: Botter.MessageHandler( 32 | //// filters: .command, 33 | //// callback: handleCommand 34 | //// ) 35 | //// ) 36 | //// 37 | //// dispatcher.add( 38 | //// handler: Botter.MessageHandler( 39 | //// filters: .photo, 40 | //// callback: handlePhoto 41 | //// ) 42 | //// ) 43 | //// 44 | //// dispatcher.add( 45 | //// handler: Botter.MessageEventHandler( 46 | //// callback: handleEvent 47 | //// ) 48 | //// ) 49 | // } 50 | // 51 | // func handleMessage(_ update: Botter.Update) throws { 52 | // guard case let .message(message) = update.content else { return } 53 | // 54 | //// try bot.sendMessage(params: .init(peerId: message.fromId!, message: "Cool image", attachments: [ .init(type: .photo, content: .fileId(photo)) ]), platform: message.platform, eventLoop: app.eventLoopGroup.next()) 55 | // 56 | // //try bot.sendMessage(params: .init(peerId: message.fromId!, message: "Starting send message"), platform: message.platform, eventLoop: app.eventLoopGroup.next()) 57 | // 58 | //// let jpgLink = "https://upload.wikimedia.org/wikipedia/en/a/a9/Example.jpg" 59 | //// let jpgData = try Data(contentsOf: URL(string: jpgLink)!) 60 | //// let txtLink = "https://www.w3.org/TR/PNG/iso_8859-1.txt" 61 | //// let txtData = try Data(contentsOf: URL(string: txtLink)!) 62 | // 63 | // guard let params = Botter.Bot.SendMessageParams(to: message, text: "that is doc") else { return } 64 | // 65 | //// if let prevMessage = prevMessage { 66 | //// try bot.editMessage(prevMessage, params: .init(message: "Other text"), app: app) 67 | //// } 68 | // 69 | // try bot.sendMessage(params, platform: message.platform.any, context: context).map(\.first).optionalFlatMap { message in 70 | // try! self.bot.editMessage(message, params: .init(message: "Other text"), app: self.app)! 71 | // } 72 | // } 73 | // 74 | // func handleEvent(_ update: Botter.Update, _ context: Botter.BotContext?) throws { 75 | // guard case let .event(event) = update.content else { return } 76 | // 77 | // try bot.sendMessageEventAnswer(.init(event: event, type: .notification(text: "BOMBOM")), platform: update.platform.any) 78 | // 79 | // let data: TestData = try! event.decodeData() 80 | // 81 | // debugPrint("event \(data) handled") 82 | // } 83 | // 84 | // struct TestData: Codable { 85 | // let text: String 86 | // } 87 | // 88 | // func handleCommand(_ update: Botter.Update, _ context: Botter.BotContext?) throws { 89 | // guard case let .message(message) = update.content, let text = message.text, let destination = message.destination, let context = context else { return } 90 | // 91 | // let textButton: Botter.Button = .init(text: "Test", action: .text) 92 | // 93 | // let params = Botter.Bot.SendMessageParams( 94 | // destination: destination, 95 | // text: text, 96 | // keyboard: .init([ [ textButton ] ]), 97 | // attachments: nil 98 | // ) 99 | // 100 | // try bot.sendMessage(params: params, platform: update.platform.any, context: context).whenComplete { res in 101 | // switch res { 102 | // case .success(_): 103 | // debugPrint("success sent message") 104 | // case let .failure(err): 105 | // debugPrint("error while sent message \(err.localizedDescription)") 106 | // } 107 | // } 108 | // } 109 | // 110 | // func handlePhoto(_ update: Botter.Update, _ context: Botter.BotContext?) throws { 111 | // guard case let .message(message) = update.content, let att = message.attachments.first else { return } 112 | // 113 | // 114 | // 115 | //// let linkButton: Botter.Button = try .init(text: "Link", action: .link(.init(link: "https://google.gik-team.com/?q=\(text)")), payload: .init("{}")) 116 | //// let callbackButton: Botter.Button = try .init(text: "Callback", action: .callback, data: TestData(text: "14342353")) 117 | //// 118 | //// let params = Botter.Bot.SendMessageParams( 119 | //// peerId: message.fromId!, 120 | //// message: text, 121 | //// keyboard: .init( 122 | //// oneTime: false, 123 | //// buttons: [ [ linkButton ], [ callbackButton ] ], 124 | //// inline: true 125 | //// ) 126 | //// ) 127 | //// 128 | //// try bot.sendMessage(params: params, platform: update.platform)!.whenComplete { res in 129 | //// switch res { 130 | //// case .success(_): 131 | //// debugPrint("success sent message") 132 | //// case let .failure(err): 133 | //// debugPrint("error while sent message \(err.localizedDescription)") 134 | //// } 135 | //// } 136 | // } 137 | //} 138 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/OrderAgreementNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 27.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderAgreementNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | systemic: true, 16 | closeable: false, 17 | name: "Order agreement node", 18 | messagesGroup: .orderAgreement, 19 | entryPoint: .orderAgreement, 20 | action: .init(.handleOrderAgreement, success: .pop), 21 | app: app 22 | ) 23 | } 24 | 25 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> EventLoopFuture<[SendMessage]>? { 26 | guard case .orderAgreement = group else { return nil } 27 | let (app, _) = (context.app, context.user) 28 | 29 | guard case let .orderAgreement(orderId) = payload else { 30 | return app.eventLoopGroup.future(error: SendMessageGroupError.invalidPayload) 31 | } 32 | 33 | return Order.find(orderId, app: app).flatMap { order in 34 | guard let order = order else { 35 | return app.eventLoopGroup.future(error: SendMessageGroupError.invalidPayload) 36 | } 37 | 38 | return CheckoutState.create(from: order, app: app).flatMapThrowing { checkoutState in 39 | context.user.nodePayload = .checkout(checkoutState) 40 | return [ .init(text: [ 41 | "Новый заказ от " + .replacing(by: .orderCustomer) + " (" + .replacing(by: .orderId) + "):", 42 | .replacing(by: .orderBlock), 43 | .replacing(by: .priceBlock), 44 | ].joined(separator: "\n"), keyboard: [ [ 45 | try Button( 46 | text: "Принять", 47 | action: .callback, 48 | eventPayload: .handleOrderAgreement(orderId: orderId, agreement: true) 49 | ), 50 | try Button( 51 | text: "Отклонить", 52 | action: .callback, 53 | eventPayload: .handleOrderAgreement(orderId: orderId, agreement: false) 54 | ) 55 | ] ]) ] 56 | } 57 | } 58 | } 59 | 60 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> EventLoopFuture<[Message]>? { 61 | guard case let .handleOrderAgreement(orderId, agreement) = eventPayload else { return nil } 62 | let (app, user) = (context.app, context.user) 63 | 64 | let future: EventLoopFuture 65 | 66 | if agreement { 67 | future = [ 68 | try user.toTwin(app: app).map { $0 as Any }, 69 | OrderModel.find(orderId, on: app.db).map { $0 as Any } 70 | ].flatten(on: app.eventLoopGroup.next()).throwingFlatMap { res in 71 | guard let user = res[0] as? UserModel, 72 | let order = res[1] as? OrderModel else { 73 | fatalError() 74 | } 75 | 76 | let query = AgreementModel.query(on: app.db) 77 | .filter(\.$order.$id, .equal, order.id!) 78 | 79 | return [ 80 | order.fetchWatchersUsers(app: app).map { $0 as Any }, 81 | try AgreementModel(order: order, approver: user).create(on: app.db) 82 | .flatMap { query.all() }.map { $0 as Any }, 83 | ].flatten(on: app.eventLoopGroup.next()).flatMap { res in 84 | guard let watchers = res[0] as? [UserModel], 85 | let agreements = res[1] as? [AgreementModel] else { 86 | fatalError() 87 | } 88 | 89 | if agreements.map(\.$approver.id).sorted() == watchers.compactMap(\.id).sorted() { 90 | return query.delete().throwingFlatMap { 91 | order.status = .inProgress 92 | return [ 93 | order.save(on: app.db), 94 | try order.$user.get(on: app.db).throwingFlatMap { try $0.toTwin(app: app) }.throwingFlatMap { 95 | try $0.sendMessage(context: context, params: .init(text: "Ваш заказ был подтвержден всеми сотрудниками!")) 96 | }.map { _ in () } 97 | ].flatten(on: app.eventLoopGroup.next()).map { _ in () } 98 | } 99 | } 100 | return app.eventLoopGroup.future() 101 | } 102 | } 103 | 104 | } else { 105 | guard let type = user.replacementType else { 106 | throw HandleActionError.nodePayloadInvalid 107 | } 108 | 109 | future = Order.find(orderId, app: app).optionalFlatMap { order in 110 | User.find(order.userId, app: app).optionalThrowingFlatMap { customer -> Future<[Message]> in 111 | guard let lastDestination = customer.lastDestination else { throw HandleActionError.nodePayloadInvalid } 112 | return customer.push(.entryPoint(.orderReplacement), payload: .orderReplacement(orderId: orderId, type: type), to: lastDestination, context: context) 113 | } 114 | }.map { _ in () } 115 | } 116 | 117 | return future.throwingFlatMap { try user.pop(to: event, context: context) }.map { _ in [] } 118 | } 119 | 120 | } 121 | 122 | extension UUID: Comparable { 123 | public static func < (lhs: UUID, rhs: UUID) -> Bool { 124 | lhs.uuidString < rhs.uuidString 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/App/Modules/Main/OrdersNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | fileprivate enum OrdersNodeControllerError: Error { 13 | case userIdNotFound 14 | } 15 | 16 | class OrdersNodeController: NodeController { 17 | func create(app: Application) throws -> EventLoopFuture { 18 | Node.create( 19 | systemic: true, 20 | name: "Orders node", 21 | messagesGroup: .list(.orders), 22 | entryPoint: .orders, app: app 23 | ) 24 | } 25 | 26 | func getListSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, listType: MessageListType, indexRange: Range) throws -> EventLoopFuture<([SendMessage], Int)>? { 27 | guard listType == .orders else { return nil } 28 | let (app, user) = (context.app, context.user) 29 | guard let userId = user.id else { throw OrdersNodeControllerError.userIdNotFound } 30 | 31 | return OrderModel.query(on: app.db).count().flatMap { count in 32 | OrderModel.query(on: app.db).group(.or) { 33 | $0.filter(\.$user.$id, .equal, userId) 34 | if let makeuperId = user.makeuperId { 35 | $0.filter(\.$makeuper.$id, .equal, makeuperId) 36 | } 37 | if let stylistId = user.stylistId { 38 | $0.filter(\.$stylist.$id, .equal, stylistId) 39 | } 40 | }.range(indexRange).all().throwingFlatMap { orders in 41 | try orders.enumerated().map { (index, model) -> Future in 42 | try model.toTwin(app: app).flatMap { order in 43 | CheckoutState.create(from: order, app: app).flatMap { checkoutState in 44 | context.user.nodePayload = .checkout(checkoutState) 45 | return MessageFormatter.shared.format( 46 | [ 47 | "Заказ от " + .replacing(by: .orderCustomer) 48 | + (user.isAdmin ? "\nID заказа (" + .replacing(by: .orderId) + "):" : ""), 49 | .replacing(by: .orderBlock), 50 | .replacing(by: .priceBlock), 51 | ].joined(separator: "\n"), 52 | platform: platform, 53 | context: context 54 | ).flatMapThrowing { text in 55 | SendMessage( 56 | text: text, 57 | keyboard: [ order.cancelAvailable(user: user) ? [ 58 | try .init(text: "Отменить", action: .callback, eventPayload: .cancelOrder(id: order.id!)) 59 | ] : []] 60 | ) 61 | } 62 | } 63 | } 64 | }.flatten(on: app.eventLoopGroup.next()) 65 | } 66 | .map { ($0, count) } 67 | } 68 | } 69 | 70 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> EventLoopFuture<[Message]>? { 71 | guard case let .cancelOrder(orderId) = eventPayload else { return nil } 72 | 73 | let (app, user, platform, bot) = (context.app, context.user, context.platform, context.bot) 74 | 75 | return OrderModel.find(orderId, on: app.db) 76 | .unwrap(or: PhotoBotError.orderByIdNotFound) 77 | .flatMap { order in 78 | 79 | order.status = .cancelled 80 | 81 | return order.save(on: app.db).throwingFlatMap { _ in 82 | try user.pushToActualNode(to: event, context: context) 83 | }.flatMap { messages in 84 | order.fetchWatchers(app: app).flatMap { watchers in 85 | CheckoutState.create(from: order, app: app).flatMap { checkoutState in 86 | 87 | func getMessage(_ platform: AnyPlatform) -> Future { 88 | context.user.nodePayload = .checkout(checkoutState) 89 | return MessageFormatter.shared.format( 90 | [ 91 | "Заказ от " + .replacing(by: .orderCustomer) + " был отменен пользователем " + .replacing(by: .username) + " " + (user.isAdmin ? "\nID заказа (" + .replacing(by: .orderId) + "):" : "") + "", 92 | .replacing(by: .orderBlock), 93 | .replacing(by: .priceBlock), 94 | ].joined(separator: "\n"), 95 | platform: platform, context: context 96 | ) 97 | } 98 | 99 | return watchers.map { watcher in 100 | let platformIds = watcher.platformIds 101 | 102 | let platformId = platformIds.first(for: platform) ?? platformIds.first! 103 | return getMessage(platformId.any).throwingFlatMap { text in 104 | try bot.sendMessage(.init( 105 | destination: platformId.sendDestination, 106 | text: text 107 | ), platform: platformId.any, context: context) 108 | } 109 | }.flatten(on: app.eventLoopGroup.next()).map { messages + $0.reduce([], +) } 110 | } 111 | }.map { messages + $0 } 112 | } 113 | } 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /Sources/App/Twinable/OrderProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | import FluentSQL 13 | 14 | enum OrderType: String, Codable { 15 | case loveStory 16 | case family 17 | case content 18 | } 19 | 20 | extension OrderType { 21 | var name: String { 22 | switch self { 23 | case .loveStory: 24 | return "Love story" 25 | case .family: 26 | return "Семейная фотосессия" 27 | case .content: 28 | return "Контент-сьемка" 29 | } 30 | } 31 | } 32 | 33 | enum OrderStatus: String, Codable { 34 | case inAgreement 35 | case inProgress 36 | case finished 37 | case cancelled 38 | } 39 | 40 | extension OrderStatus: CustomStringConvertible { 41 | var description: String { 42 | switch self { 43 | case .inAgreement: 44 | return "На согласовании" 45 | 46 | case .inProgress: 47 | return "На исполнении" 48 | 49 | case .finished: 50 | return "Завершен" 51 | 52 | case .cancelled: 53 | return "Отменен" 54 | } 55 | } 56 | } 57 | 58 | protocol OrderProtocol: PromotionsProtocol, Twinable where TwinType: OrderProtocol { 59 | 60 | associatedtype ImplementingModel = OrderModel 61 | associatedtype SiblingModel = OrderPromotion 62 | 63 | var id: UUID? { get set } 64 | var userId: UUID! { get set } 65 | var type: OrderType! { get set } 66 | var status: OrderStatus { get set } 67 | var stylistId: UUID? { get set } 68 | var makeuperId: UUID? { get set } 69 | var photographerId: UUID? { get set } 70 | var studioId: UUID? { get set } 71 | var interval: DateInterval { get set } 72 | var hourPrice: Float { get set } 73 | var promotions: [PromotionModel] { get set } 74 | 75 | init() 76 | static func create(id: UUID?, userId: UUID, type: OrderType, stylistId: UUID?, makeuperId: UUID?, photographerId: UUID?, studioId: UUID?, interval: DateInterval, price: Float, promotions: [PromotionModel], status: OrderStatus, app: Application) -> Future 77 | } 78 | 79 | enum OrderCreateError: Error { 80 | case noDateOrType 81 | } 82 | 83 | extension OrderProtocol { 84 | static func create(other: TwinType, app: Application) throws -> Future { 85 | other.getPromotions(app: app).flatMap { promotions in 86 | Self.create(id: other.id, userId: other.userId, type: other.type, stylistId: other.stylistId, makeuperId: other.makeuperId, photographerId: other.photographerId, studioId: other.studioId, interval: other.interval, price: other.hourPrice, promotions: promotions, status: other.status, app: app) 87 | } 88 | } 89 | 90 | static func create(id: UUID? = nil, userId: UUID, type: OrderType, stylistId: UUID?, makeuperId: UUID?, photographerId: UUID?, studioId: UUID?, interval: DateInterval, price: Float = 0, promotions: [PromotionModel], status: OrderStatus = .inAgreement, app: Application) -> Future { 91 | let instance = Self.init() 92 | instance.id = id 93 | instance.userId = userId 94 | instance.type = type 95 | instance.stylistId = stylistId 96 | instance.makeuperId = makeuperId 97 | instance.studioId = studioId 98 | instance.photographerId = photographerId 99 | instance.interval = interval 100 | instance.hourPrice = price 101 | instance.status = status 102 | return instance.saveIfNeeded(app: app).throwingFlatMap { 103 | try $0.attachPromotions(promotions, app: app).transform(to: instance) 104 | } 105 | } 106 | 107 | static func create(id: UUID? = nil, userId: UUID, checkoutState: CheckoutState, app: Application) throws -> Future { 108 | let order = checkoutState.order 109 | guard let date = order.date, 110 | let duration = order.duration, 111 | let type = order.type else { throw OrderCreateError.noDateOrType } 112 | return Self.create( 113 | userId: userId, 114 | type: type, 115 | stylistId: order.stylistId, 116 | makeuperId: order.makeuperId, 117 | photographerId: order.photographerId, 118 | studioId: order.studioId, 119 | interval: .init(start: date, duration: duration), 120 | price: order.hourPrice, 121 | promotions: checkoutState.promotions, 122 | app: app 123 | ) 124 | } 125 | 126 | func merge(with order: OrderState) { 127 | if let date = order.date { 128 | interval.start = date 129 | } 130 | if let duration = order.duration { 131 | interval.duration = duration 132 | } 133 | if let type = order.type { 134 | self.type = type 135 | } 136 | if let stylistId = order.stylistId { 137 | self.stylistId = stylistId 138 | } 139 | if let makeuperId = order.makeuperId { 140 | self.makeuperId = makeuperId 141 | } 142 | if let photographerId = order.photographerId { 143 | self.photographerId = photographerId 144 | } 145 | if let studioId = order.studioId { 146 | self.studioId = studioId 147 | } 148 | 149 | } 150 | } 151 | 152 | extension OrderState { 153 | func getMergedUser(app: Application) -> Future? { 154 | if let photographerId = photographerId { 155 | return Photographer.find(photographerId, app: app).optionalFlatMap { $0.getUser(app: app) } 156 | } 157 | if let studioId = studioId { 158 | return Studio.find(studioId, app: app).optionalFlatMap { $0.getUser(app: app) } 159 | } 160 | if let stylistId = stylistId { 161 | return Stylist.find(stylistId, app: app).optionalFlatMap { $0.getUser(app: app) } 162 | } 163 | if let makeuperId = makeuperId { 164 | return Makeuper.find(makeuperId, app: app).optionalFlatMap { $0.getUser(app: app) } 165 | } 166 | return nil 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Sources/App/Types/PromotionCondition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 04.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Botter 11 | 12 | enum PromotionCondition { 13 | 14 | indirect case and([PromotionCondition]) 15 | indirect case or([PromotionCondition]) 16 | case numeric(NumericKey, NumericCondition, Int) 17 | case equals(EquatableKey, UUID) 18 | 19 | enum EquatableKey: String, Codable { 20 | case studio 21 | case makeuper 22 | case stylist 23 | } 24 | 25 | enum NumericKey: String, Codable { 26 | case price 27 | case peopleCount 28 | case propsCount 29 | case orderCount 30 | } 31 | 32 | enum NumericCondition: String, Codable { 33 | case less 34 | case more 35 | case equals 36 | } 37 | 38 | } 39 | 40 | extension PromotionCondition { 41 | 42 | func check(state: OrderState, context: PhotoBotContextProtocol) -> Future { 43 | Self.check(state: state, condition: self, context: context) 44 | } 45 | 46 | static func check(state: OrderState, condition: Self, context: PhotoBotContextProtocol) -> Future { 47 | let app = context.app 48 | 49 | switch condition { 50 | case let .and(arr), let .or(arr): 51 | return arr.map { $0.check(state: state, context: context) }.flatten(on: app.eventLoopGroup.next()).map { 52 | switch condition { 53 | case .and: 54 | return $0.allSatisfy { $0 } 55 | case .or: 56 | return $0.contains { $0 } 57 | default: fatalError() 58 | } 59 | } 60 | case let .numeric(lhs, numCondition, rhs): 61 | return lhs.getValue(state: state, context: context).map { lhsNum in 62 | switch numCondition { 63 | case .less: 64 | return lhsNum < rhs 65 | case .more: 66 | return lhsNum > rhs 67 | case .equals: 68 | return lhsNum == rhs 69 | } 70 | } 71 | case let .equals(lhs, rhs): 72 | return app.eventLoopGroup.future(lhs.getId(state: state) == rhs) 73 | } 74 | } 75 | 76 | } 77 | 78 | extension PromotionCondition.NumericKey { 79 | func getValue(state: OrderState, context: PhotoBotContextProtocol) -> Future { 80 | let (app, user) = (context.app, context.user) 81 | 82 | switch self { 83 | case .price: 84 | return app.eventLoopGroup.future(Int(state.hourPrice)) 85 | case .peopleCount: 86 | return app.eventLoopGroup.future(0) 87 | case .propsCount: 88 | return app.eventLoopGroup.future(0) 89 | case .orderCount: 90 | return OrderModel.query(on: app.db).filter("user_id", .equal, user.id).count() 91 | } 92 | } 93 | } 94 | 95 | extension PromotionCondition.EquatableKey { 96 | func getId(state: OrderState) -> UUID? { 97 | switch self { 98 | case .makeuper: 99 | return state.makeuperId 100 | case .studio: 101 | return state.studioId 102 | case .stylist: 103 | return state.stylistId 104 | } 105 | } 106 | } 107 | 108 | extension PromotionCondition: Codable { 109 | 110 | enum CodingKeys: String, CodingKey { 111 | case and 112 | case or 113 | case numericLhs 114 | case numericCondition 115 | case numericRhs 116 | case equalsLhs 117 | case equalsRhs 118 | } 119 | 120 | internal init(from decoder: Decoder) throws { 121 | let container = try decoder.container(keyedBy: CodingKeys.self) 122 | 123 | if container.allKeys.contains(.and), try container.decodeNil(forKey: .and) == false { 124 | let associatedValue0 = try container.decode([PromotionCondition].self, forKey: .and) 125 | self = .and(associatedValue0) 126 | return 127 | } 128 | if container.allKeys.contains(.or), try container.decodeNil(forKey: .or) == false { 129 | let associatedValue0 = try container.decode([PromotionCondition].self, forKey: .or) 130 | self = .or(associatedValue0) 131 | return 132 | } 133 | if container.allKeys.contains(.numericLhs), try container.decodeNil(forKey: .numericLhs) == false { 134 | let associatedValue0 = try container.decode(NumericKey.self, forKey: .numericLhs) 135 | let associatedValue1 = try container.decode(NumericCondition.self, forKey: .numericCondition) 136 | let associatedValue2 = try container.decode(Int.self, forKey: .numericRhs) 137 | self = .numeric(associatedValue0, associatedValue1, associatedValue2) 138 | return 139 | } 140 | if container.allKeys.contains(.equalsLhs), try container.decodeNil(forKey: .equalsLhs) == false { 141 | let associatedValue0 = try container.decode(EquatableKey.self, forKey: .equalsLhs) 142 | let associatedValue1 = try container.decode(UUID.self, forKey: .equalsRhs) 143 | self = .equals(associatedValue0, associatedValue1) 144 | return 145 | } 146 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown enum case")) 147 | } 148 | 149 | internal func encode(to encoder: Encoder) throws { 150 | var container = encoder.container(keyedBy: CodingKeys.self) 151 | 152 | switch self { 153 | case let .and(associatedValue0): 154 | try container.encode(associatedValue0, forKey: .and) 155 | case let .or(associatedValue0): 156 | try container.encode(associatedValue0, forKey: .or) 157 | case let .numeric(associatedValue0, associatedValue1, associatedValue2): 158 | try container.encode(associatedValue0, forKey: .numericLhs) 159 | try container.encode(associatedValue1, forKey: .numericCondition) 160 | try container.encode(associatedValue2, forKey: .numericRhs) 161 | case let .equals(associatedValue0, associatedValue1): 162 | try container.encode(associatedValue0, forKey: .equalsLhs) 163 | try container.encode(associatedValue1, forKey: .equalsRhs) 164 | } 165 | } 166 | 167 | } 168 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/OrderCheckoutNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 20.03.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderCheckoutNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order checkout node", 16 | messagesGroup: .orderCheckout, 17 | entryPoint: .orderCheckout, action: .init(.applyPromocode), app: app 18 | ) 19 | } 20 | 21 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> EventLoopFuture<[SendMessage]>? { 22 | guard case .orderCheckout = group else { return nil } 23 | 24 | let app = context.app 25 | 26 | return app.eventLoopGroup.future([ .init( 27 | text: [ 28 | "Оформление заказа", 29 | .replacing(by: .orderBlock), 30 | .replacing(by: .priceBlock), 31 | .replacing(by: .promoBlock), 32 | ].joined(separator: "\n"), 33 | keyboard: [[ 34 | try .init(text: "✅ Отправить", action: .callback, eventPayload: .createOrder) 35 | ]] 36 | ) ]) 37 | } 38 | 39 | func handleAction(_ action: NodeAction, _ message: Message, context: PhotoBotContextProtocol) throws -> EventLoopFuture>? { 40 | guard case .applyPromocode = action.type, let text = message.text else { return nil } 41 | let (app, user) = (context.app, context.user) 42 | 43 | guard case var .checkout(checkoutState) = user.nodePayload else { throw HandleActionError.nodePayloadInvalid } 44 | 45 | return Promotion.find(promocode: text, app: app).flatMap { promotion in 46 | guard let promotion = promotion else { return app.eventLoopGroup.future(.failure(.promoNotFound)) } 47 | 48 | return promotion.condition.check(state: checkoutState.order, context: context).flatMap { check in 49 | guard check else { return app.eventLoopGroup.future(.failure(.promoCondition)) } 50 | 51 | return checkoutState.promotions.map { Promotion.find($0.id, app: app) }.flatten(on: app.eventLoopGroup.next()).throwingFlatMap { promotions in 52 | 53 | for promo in promotions.compactMap({ $0 }) where !promo.autoApply { 54 | if let promoId = promo.id { 55 | if let index = checkoutState.promotions.compactMap(\.id).firstIndex(of: promoId) { 56 | checkoutState.promotions.remove(at: index) 57 | } 58 | } 59 | } 60 | 61 | return try promotion.toTwin(app: app).flatMap { promotionModel in 62 | checkoutState.promotions.append(promotionModel) 63 | 64 | return user.push(.entryPoint(.orderCheckout), payload: .checkout(checkoutState), to: message, context: context).map { _ in .success } 65 | } 66 | } 67 | 68 | 69 | } 70 | } 71 | } 72 | 73 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> Future<[Botter.Message]>? { 74 | guard case .createOrder = eventPayload else { return nil } 75 | 76 | let (app, user, bot) = (context.app, context.user, context.bot) 77 | 78 | replyText = "Move" 79 | 80 | guard case let .checkout(checkoutState) = user.nodePayload, let userId = user.id else { throw HandleActionError.nodePayloadInvalid } 81 | 82 | let platform = event.platform.any 83 | 84 | return try OrderModel.create(userId: userId, checkoutState: checkoutState, app: app).flatMap { order in 85 | MessageFormatter.shared.format("Заказ успешно создан! После подтверждения заказа мы уведомим вас о готовности. По всем вопросам - к @" + .replacing(by: .admin), platform: platform, context: context) 86 | .throwingFlatMap { message in 87 | try event.replyMessage(.init(text: message), context: context) 88 | }.map { ($0, order) } 89 | }.flatMap { (messages, order) in 90 | CheckoutState.create(from: order, app: app).throwingFlatMap { orderState in 91 | context.user.nodePayload = .checkout(orderState) 92 | return try User.find( 93 | destination: .username(Application.adminNickname(for: platform)), 94 | platform: platform, 95 | app: app 96 | ).flatMap { user in 97 | 98 | let futures: [Future<[Message]>] = [ 99 | order.fetchWatchers(app: app).throwingFlatMap { 100 | try $0.map { watcher in 101 | try watcher.getPlatformUser(app: app) 102 | .optionalThrowingFlatMap { try $0.toTwin(app: app) } 103 | .throwingFlatMap { user in 104 | guard let user = user, let lastDestination = user.lastDestination else { return app.eventLoopGroup.future([]) } 105 | return user.push( 106 | .entryPoint(.orderAgreement), 107 | payload: .orderAgreement(orderId: try order.requireID()), 108 | to: lastDestination, context: context 109 | ) 110 | } 111 | }.flatten(on: app.eventLoopGroup.next()) 112 | .map { messages + $0.reduce([], +) } 113 | } 114 | ] 115 | 116 | return futures.flatten(on: app.eventLoopGroup.next()).map { $0.reduce([], +) } 117 | } 118 | } 119 | }.throwingFlatMap { messages in 120 | try user.popToMain(to: event, context: context).map { messages + $0 } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/App/Modules/Order/OrderReplacementNodeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 06.06.2021. 6 | // 7 | 8 | import Foundation 9 | import Botter 10 | import Vapor 11 | 12 | class OrderReplacementNodeController: NodeController { 13 | func create(app: Application) throws -> EventLoopFuture { 14 | Node.create( 15 | name: "Order replacement node", 16 | messagesGroup: .orderReplacement, 17 | entryPoint: .orderReplacement, app: app 18 | ) 19 | } 20 | 21 | func getSendMessages(platform: AnyPlatform, in node: Node, _ payload: NodePayload?, context: PhotoBotContextProtocol, group: SendMessageGroup) throws -> EventLoopFuture<[SendMessage]>? { 22 | guard case .orderReplacement = group else { return nil } 23 | 24 | let (app, user) = (context.app, context.user) 25 | 26 | switch payload { 27 | case let .orderReplacement(orderId, type): 28 | return PhotographerModel.query(on: app.db).first().optionalThrowingFlatMap { try $0.toTwin(app: app) } 29 | .flatMapThrowing { photographer in 30 | [ 31 | .init(text: "Один из работников, похоже, не сможет участвовать в заказе. Хочешь найти замену или отменить заказ?", keyboard: [[ 32 | try .init(text: "Отменить", action: .callback, eventPayload: .handleOrderReplacement(false)), 33 | try .init(text: "Найти", action: .callback, eventPayload: .handleOrderReplacement(true)), 34 | ]]), 35 | ] 36 | } 37 | 38 | case let .orderBuilder(state): 39 | 40 | guard case let .orderReplacement(orderId, _) = user.history.last?.nodePayload else { 41 | return app.eventLoopGroup.future(error: SendMessageGroupError.invalidPayload) 42 | } 43 | 44 | var future: EventLoopFuture 45 | 46 | if let makeuperId = state.makeuperId { 47 | future = Makeuper.find(makeuperId, app: app).map { $0?.name } 48 | } else if let stylistId = state.stylistId { 49 | future = Stylist.find(stylistId, app: app).map { $0?.name } 50 | } else if let photographId = state.photographerId { 51 | future = Photographer.find(photographId, app: app).map { $0?.name } 52 | } else if let studioId = state.studioId { 53 | future = Studio.find(studioId, app: app).map { $0?.name } 54 | } else { 55 | return app.eventLoopGroup.future(error: SendMessageGroupError.invalidPayload) 56 | } 57 | 58 | return future.unwrap(orError: SendMessageGroupError.invalidPayload).flatMapThrowing { 59 | [ 60 | SendMessage(text: "Подтвердить замену на \($0)", keyboard: [[ 61 | try .init(text: "Да", action: .callback, eventPayload: .applyOrderReplacement(orderId: orderId, state: state)), 62 | ]]) 63 | ] 64 | } 65 | 66 | default: 67 | return app.eventLoopGroup.future(error: SendMessageGroupError.invalidPayload) 68 | } 69 | } 70 | 71 | func handleEventPayload(_ event: MessageEvent, _ eventPayload: EventPayload, _ replyText: inout String, context: PhotoBotContextProtocol) throws -> Future<[Botter.Message]>? { 72 | let (app, user, _) = (context.app, context.user, context.bot) 73 | 74 | switch eventPayload { 75 | case let .applyOrderReplacement(orderId, state): 76 | replyText = "Move" 77 | 78 | return OrderModel.find(orderId, on: app.db) 79 | .unwrap(or: SendMessageGroupError.invalidPayload) 80 | .flatMap { model in 81 | model.merge(with: state) 82 | return model.update(on: app.db).map { model } 83 | }.throwingFlatMap { (order: OrderModel) in 84 | 85 | guard let userFuture = state.getMergedUser(app: app) else { 86 | fatalError() 87 | } 88 | 89 | return [ 90 | userFuture.optionalThrowingFlatMap { try $0.toTwin(app: app) }.throwingFlatMap { user in 91 | guard let user = user, let lastDestination = user.lastDestination else { return app.eventLoopGroup.future([]) } 92 | return user.push( 93 | .entryPoint(.orderAgreement), 94 | payload: .orderAgreement(orderId: try order.requireID()), 95 | to: lastDestination, context: context 96 | ) 97 | }, 98 | try event.replyMessage(.init(text: "Изменения в заказе были успешно применены."), context: context).throwingFlatMap { messages in 99 | try user.popToDifferentNode(to: event, context: context).map { $0 + messages } 100 | } 101 | ].flatten(on: app.eventLoopGroup.next()).map { $0.reduce([], +) } 102 | } 103 | 104 | case let .handleOrderReplacement(replacement): 105 | guard case let .orderReplacement(orderId, type) = user.nodePayload else { 106 | throw SendMessageGroupError.invalidPayload 107 | } 108 | 109 | return OrderModel.find(orderId, on: app.db) 110 | .throwingFlatMap { order in 111 | guard let order = order else { 112 | throw SendMessageGroupError.invalidPayload 113 | } 114 | 115 | if replacement { 116 | return user.push(.entryPoint(type.listNodeEntryPoint), payload: .orderBuilder(.init(type: order.type)), to: event, context: context) 117 | } else { 118 | order.status = .cancelled 119 | 120 | return order.save(on: app.db).throwingFlatMap { 121 | try event.replyMessage(.init(text: "Заказ был успешно отменен."), context: context) 122 | } 123 | .throwingFlatMap { _ in try user.popToMain(to: event, context: context) } 124 | } 125 | } 126 | 127 | default: 128 | return nil 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/App/Twinable/UserProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nickolay Truhin on 27.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import Botter 12 | 13 | struct UserHistoryEntry: Codable { 14 | let nodeId: UUID 15 | let nodePayload: NodePayload? 16 | } 17 | 18 | extension Array where Element == UserHistoryEntry { 19 | var firstOrderBuildable: UserHistoryEntry? { 20 | let orderBuildableIds = EntryPoint.orderBuildable.compactMap { Node.entryPointIds[$0] } 21 | return reversed().first { orderBuildableIds.contains($0.nodeId) } 22 | } 23 | } 24 | 25 | struct UserPlatformId: Codable, Hashable { 26 | let id: Int64 // chatId for tg, userId for vk 27 | let username: String? 28 | } 29 | 30 | extension TypedPlatform where Tg == UserPlatformId, Vk == UserPlatformId { 31 | var sendDestination: SendDestination { 32 | switch self { 33 | case let .tg(tg): 34 | // tg doesn't support send message by username 35 | return .chatId(tg.id) 36 | 37 | case let .vk(vk): 38 | if let username = vk.username { 39 | return .username(username) 40 | } 41 | return .userId(vk.id) 42 | } 43 | } 44 | } 45 | 46 | extension TypedPlatform where Tg == UserPlatformId, Vk == UserPlatformId { 47 | 48 | private var baseString: String? { 49 | let str: String 50 | switch self { 51 | case let .tg(platformId): 52 | guard let username = platformId.username else { return nil } 53 | str = username 54 | 55 | case let .vk(platformId): 56 | if let username = platformId.username { 57 | str = username 58 | } else { 59 | str = "id\(String(platformId.id))" 60 | } 61 | } 62 | return str 63 | } 64 | 65 | var mention: String? { 66 | guard let baseString = baseString else { return nil } 67 | return "@\(baseString)" 68 | } 69 | 70 | private var platformUrlPrefix: String { 71 | switch self { 72 | case .tg: 73 | return "https://t.me/" 74 | 75 | case .vk: 76 | return "https://vk.com/" 77 | } 78 | } 79 | 80 | var link: String? { 81 | guard let baseString = baseString else { return nil } 82 | return platformUrlPrefix + baseString 83 | } 84 | 85 | } 86 | 87 | struct UserDestination: PlatformObject, Replyable, Codable { 88 | var destination: SendDestination? 89 | var platform: AnyPlatform 90 | } 91 | 92 | protocol UserProtocol: PlatformIdentifiable, Twinable where TwinType: UserProtocol { 93 | 94 | var id: UUID? { get set } 95 | var history: [UserHistoryEntry] { get set } 96 | var nodeId: UUID? { get set } 97 | var nodePayload: NodePayload? { get set } 98 | var isAdmin: Bool { get set } 99 | var firstName: String? { get set } 100 | var lastName: String? { get set } 101 | var makeuper: MakeuperModel? { get set } 102 | var makeuperId: UUID? { get } 103 | var stylist: StylistModel? { get set } 104 | var stylistId: UUID? { get } 105 | var photographer: PhotographerModel? { get set } 106 | var photographerId: UUID? { get } 107 | var studio: StudioModel? { get set } 108 | var studioId: UUID? { get } 109 | var lastDestination: UserDestination? { get set } 110 | 111 | init() 112 | static func create(id: UUID?, history: [UserHistoryEntry], nodeId: UUID?, nodePayload: NodePayload?, platformIds: [TypedPlatform], isAdmin: Bool, firstName: String?, lastName: String?, makeuper: MakeuperModel?, stylist: StylistModel?, photographer: PhotographerModel?, studio: StudioModel?, lastDestination: UserDestination?, app: Application) -> Future 113 | } 114 | 115 | extension UserProtocol { 116 | var watcherIds: [UUID] { 117 | [stylistId, photographerId, makeuperId].compactMap { $0 } 118 | } 119 | 120 | var replacementType: MessageListType? { 121 | if stylistId != nil { 122 | return .stylists 123 | } else if photographerId != nil { 124 | return .photographers 125 | } else if makeuperId != nil { 126 | return .makeupers 127 | } else if studioId != nil { 128 | return .studios 129 | } 130 | return nil 131 | } 132 | 133 | static func create(other: TwinType, app: Application) throws -> Future { 134 | [ 135 | StylistModel.find(other.stylistId, on: app.db).map { $0 as Any }, 136 | MakeuperModel.find(other.makeuperId, on: app.db).map { $0 as Any }, 137 | PhotographerModel.find(other.photographerId, on: app.db).map { $0 as Any }, 138 | StudioModel.find(other.studioId, on: app.db).map { $0 as Any }, 139 | ].flatten(on: app.eventLoopGroup.next()).flatMap { 140 | let (stylist, makeuper, photographer, studio) = ($0[0] as? StylistModel, $0[1] as? MakeuperModel, $0[2] as? PhotographerModel, $0[3] as? StudioModel) 141 | return Self.create(id: other.id, history: other.history, nodeId: other.nodeId, nodePayload: other.nodePayload, platformIds: other.platformIds, isAdmin: other.isAdmin, firstName: other.firstName, lastName: other.lastName, makeuper: makeuper, stylist: stylist, photographer: photographer, studio: studio, lastDestination: other.lastDestination, app: app) 142 | } 143 | } 144 | 145 | static func create(id: UUID? = nil, history: [UserHistoryEntry] = [], nodeId: UUID? = nil, nodePayload: NodePayload? = nil, platformIds: [TypedPlatform], isAdmin: Bool = false, firstName: String?, lastName: String?, makeuper: MakeuperModel? = nil, stylist: StylistModel? = nil, photographer: PhotographerModel? = nil, studio: StudioModel? = nil, lastDestination: UserDestination? = nil, app: Application) -> Future { 146 | var instance = Self.init() 147 | instance.id = id 148 | instance.history = history 149 | instance.nodeId = nodeId 150 | instance.nodePayload = nodePayload 151 | instance.platformIds = platformIds 152 | instance.isAdmin = isAdmin 153 | instance.firstName = firstName 154 | instance.lastName = lastName 155 | instance.makeuper = makeuper 156 | instance.stylist = stylist 157 | instance.photographer = photographer 158 | instance.studio = studio 159 | instance.lastDestination = lastDestination 160 | return instance.saveIfNeeded(app: app) 161 | } 162 | } 163 | --------------------------------------------------------------------------------