diff --git a/swift/send-message/.gitignore b/swift/send-message/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/swift/send-message/Package.swift b/swift/send-message/Package.swift new file mode 100644 index 00000000..d0b2b377 --- /dev/null +++ b/swift/send-message/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "send-message", + dependencies: [ + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "2.0.3"), + ], + targets: [ + .executableTarget( + name: "send-message", + dependencies: [ + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "Crypto", package: "swift-crypto"), + ], + path: "Sources" + ), + ] +) \ No newline at end of file diff --git a/swift/send-message/Readme.md b/swift/send-message/Readme.md new file mode 100644 index 00000000..a551018f --- /dev/null +++ b/swift/send-message/Readme.md @@ -0,0 +1,128 @@ +# sendMessage() + +A Swift Cloud Function for sending a message using a specific channel to a receiver + +Supported channels are `SMS`, `Email` ,`Discord` and `Twitter`. + +_SMS Example payload_ + +```json +{ + "type": "SMS", + "recipient": "+123456789", + "content": "Programming is fun!" +} +``` + +_Email Example payload_ + +```json +{ + "type": "Email", + "recipient": "hello@example.com", + "content": "Programming is fun!", + "subject": "Programming is funny!" +} +``` + +_Discord Example payload_ + +```json +{ + "type": "Discord", + "content": "Hi", + "recipient": "username" +} +``` + +_Twitter Example payload_ + +```json +{ + "type": "Twitter", + "recipient": "", + "content": "Programming is fun!" +} +``` +- User specified in receiver will be tagged at the beginning of the tweet as a 'reply'. If no user specified, tweet will be a standard tweet. + + +_Successful function response:_ + +```json +{ + "success": true +} +``` + +_Error function response:_ + +```json +{ + "success": false, + "message": "Misconfigurtion Error: Missing environment variables." +} +``` + +## 📝 Variables + +List of variables used by this cloud function: + +Mailgun + +- **MAILGUN_API_KEY** - API Key for Mailgun +- **MAILGUN_DOMAIN** - Domain Name from Mailgun +- **MAILGUN_FROM_EMAIL_ADDRESS** - Email value for sender + +- Sender email address defaults to "me@samples.mailgun.org" if left empty. Format value as "Name " to display a name and email or "name@domain.com" for just the email. + +Discord + +- **DISCORD_BOT_TOKEN** - API token for Discord bot +- **DISCORD_GUILD_ID** - Discord server ID + +Twilio + +- **TWILIO_ACCOUNT_SID** - Acount SID from Twilio +- **TWILIO_AUTH_TOKEN** - Auth Token from Twilio +- **TWILIO_SENDER** - Sender Phone Number from Twilio + +Twitter + +- **TWITTER_API_KEY** - API Key for Twitter +- **TWITTER_API_KEY_SECRET** - API Key Secret for Twitter +- **TWITTER_ACCESS_TOKEN** - Access Token from Twitter +- **TWITTER_ACCESS_TOKEN_SECRET** - Access Token Secret from Twitter + +## 🚀 Deployment + +1. Clone this repository, and enter this function folder: + +```bash +$ git clone https://github.com/open-runtimes/examples.git && cd examples +$ cd swift/send-message +``` + +2. Build the code: + +```bash +docker run --rm --interactive --tty --volume $PWD:/usr/code openruntimes/swift:v2-5.5 sh /usr/local/src/build.sh +``` +As a result, a `code.tar.gz` file will be generated. + +3. Start the Open Runtime: + +```bash +docker run -p 3000:3000 -e INTERNAL_RUNTIME_KEY=secret-key --rm --interactive --tty --volume $PWD/code.tar.gz:/tmp/code.tar.gz:ro openruntimes/swift:v2-5.5 sh /usr/local/src/start.sh +``` +Your function is now listening on port `3000`, and you can execute it by sending `POST` request with appropriate authorization headers. To learn more about runtime, you can visit Swift runtime [README](https://github.com/open-runtimes/open-runtimes/tree/main/runtimes/swift-5.5). + +4. Curl Command ( Email ) + +```bash +curl -X POST http://localhost:3000/ -d '{"variables": {"MAILGUN_API_KEY":"YOUR_MAILGUN_API_KEY","MAILGUN_DOMAIN":"YOUR_MAILGUN_DOMAIN"},"payload": "{\"type\": \"email\",\"recipient\": \"hello@example.com\",\"content\": \"Programming is fun!\",\"subject\": \"Programming is funny!\"}"}' -H "X-Internal-Challenge: secret-key" -H "Content-Type: application/json" +``` + +## 📝 Notes + +- This function is designed for use with Appwrite Cloud Functions. You can learn more about it in [Appwrite docs](https://appwrite.io/docs/functions). diff --git a/swift/send-message/Sources/DiscordMessenger.swift b/swift/send-message/Sources/DiscordMessenger.swift new file mode 100644 index 00000000..58dea488 --- /dev/null +++ b/swift/send-message/Sources/DiscordMessenger.swift @@ -0,0 +1,121 @@ +import AsyncHTTPClient +import Foundation +import NIO +import NIOFoundationCompat + +let createDMURL = "https://discord.com/api/v10/users/@me/channels" + +class DiscordMessenger: Messenger{ + private let discordBotToken: String + private let discordGuildID: String + private let httpClient: HTTPClient + + init(_ env_vars: [String: String], httpClient: HTTPClient) throws { + guard let discordBotToken = env_vars["DISCORD_BOT_TOKEN"], + let discordGuildID = env_vars["DISCORD_GUILD_ID"] else { + throw MessengerError.misconfigurationError(error: "Missing environment variables.") + } + self.discordBotToken = discordBotToken + self.discordGuildID = discordGuildID + self.httpClient = httpClient + } + + private func getIDFromUsername(json: [[String:Any]], username: String) throws -> String{ + for user in json{ + let currentUsername = ((user["user"]) as! [String: Any])["username"] as! String + + if currentUsername == username{ + return ((user["user"]) as! [String: Any])["id"] as! String + } + } + throw MessengerError.misconfigurationError(error: "No users with username \(username) found in server") + } + + //Returns a recipient id given their username + private func getRecipientID(username: String) async throws -> String{ + let targetURL = "https://discord.com/api/v10/guilds/\(discordGuildID)/members/search?query=\(username)&limit=1000" + var request = HTTPClientRequest(url: targetURL) + + request.method = .GET + request.headers.add(name: "Authorization", value: discordBotToken) + + let response:HTTPClientResponse + do{ + response = try await httpClient.execute(request, timeout: .seconds(30)) + }catch{ + throw MessengerError.providerError(error: "Request url not responding or connection timed out") + } + + let responseBody: ByteBuffer = try await response.body.collect(upTo: 1024 * 1024) // 1 MB + let responseBodyJson = try JSONSerialization.jsonObject(with: responseBody.getData(at: 0, length: responseBody.readableBytes)!, options: []) + + //If no user is returned we still get a status 200. searchUsersByUsername() will throw is misconfiguration error if there are no users that match the username + if response.status != .ok { + throw MessengerError.validationError(error: "Unable to get recipient id, API Status Code: \(response.status), API Error Message: \((responseBodyJson as! [String: Any])["message"] ?? "none")") + } + + return try getIDFromUsername(json: responseBodyJson as! Array<[String: Any]>, username: username) + } + + //Returns dm channel ID. If there isn't already a dm channel with a user one is made. + private func createDM(recipient_id: String) async throws -> String{ + var request = HTTPClientRequest(url: createDMURL) + let jsonRecipientID: [String: String] = ["recipient_id": "\(recipient_id)"] + + request.method = .POST + request.headers.add(name: "Content-Type", value: "application/json") + request.headers.add(name: "Authorization", value: discordBotToken) + request.body = .bytes(ByteBuffer(data: (try JSONSerialization.data(withJSONObject: jsonRecipientID)))) + + let response:HTTPClientResponse + do{ + response = try await httpClient.execute(request, timeout: .seconds(30)) + }catch{ + throw MessengerError.providerError(error: "Request url not responding or connection timed out") + } + + let responseBody: ByteBuffer = try await response.body.collect(upTo: 1024 * 1024) // 1 MB + let responseBodyJson = try JSONSerialization.jsonObject(with: responseBody.getData(at: 0, length: responseBody.readableBytes)!, options: []) as! [String: Any] + + if response.status != .ok { + throw MessengerError.validationError(error: "Unable to create dm, API Status Code: \(response.status), API Error Message: \(responseBodyJson["message"] ?? "none")") + } + + return responseBodyJson["id"] as! String + } + + //Sends a message given a Message with members recipient and message. + //Returns an Error type if there is an error, otherwise returns nil + public func sendMessage(messageRequest: Message) async -> Error?{ + let dmChannelID:String + do{ + dmChannelID = try await createDM(recipient_id: getRecipientID(username: messageRequest.recipient)) + }catch{ + return error + } + + let targetURL = "https://discord.com/api/v10/channels/\(dmChannelID)/messages" + var request = HTTPClientRequest(url: targetURL) + let jsonMessage: [String: String] = ["content": "\(messageRequest.content)"] + + request.method = .POST + request.headers.add(name: "Content-Type", value: "application/json") + request.headers.add(name: "Authorization", value: discordBotToken) + + do{ + request.body = .bytes(ByteBuffer(data: (try JSONSerialization.data(withJSONObject: jsonMessage)))) + let response = try await httpClient.execute(request, timeout: .seconds(30)) + if response.status == .ok { + //if everything appears to have worked, return nil + return nil + } + let responseBody: ByteBuffer = try await response.body.collect(upTo: 1024 * 1024) // 1 MB + let responseBodyJson = try JSONSerialization.jsonObject(with: responseBody.getData(at: 0, length: responseBody.readableBytes)!, options: []) as! [String: Any] + return MessengerError.validationError(error: "Unable to send dm, API Status Code: \(response.status), API Error Message: \(responseBodyJson["message"] ?? "none")") + }catch{ + return MessengerError.providerError(error: "Request url not responding or connection timed out") + } + + } + +} \ No newline at end of file diff --git a/swift/send-message/Sources/EmailMessenger.swift b/swift/send-message/Sources/EmailMessenger.swift new file mode 100644 index 00000000..7f17c073 --- /dev/null +++ b/swift/send-message/Sources/EmailMessenger.swift @@ -0,0 +1,56 @@ +import AsyncHTTPClient +import Foundation +import NIO +import NIOFoundationCompat + +class EmailMessenger: Messenger{ + + private let mailgunAPIKey: String + private let mailgunDomain: String + private let fromEmailAddress: String + private let httpClient: HTTPClient + + init(_ env_vars: [String: String], httpClient: HTTPClient) throws{ + guard let mailgunAPIKey = env_vars["MAILGUN_API_KEY"], + let mailgunDomain = env_vars["MAILGUN_DOMAIN"] else { + throw MessengerError.misconfigurationError(error: "Missing environment variables.") + } + self.mailgunAPIKey = mailgunAPIKey + self.mailgunDomain = mailgunDomain + + //sender of the message's email and optionally their name. Can be a non-existent email as long as it is formatted correctly. + //for example “Bob ” or "me@samples.mailgun.org" or "5555@5555.5555". + fromEmailAddress = env_vars["MAILGUN_FROM_EMAIL_ADDRESS"] ?? "me@samples.mailgun.org" + self.httpClient = httpClient + } + + public func sendMessage(messageRequest: Message) async -> Error? { + let targetURL:String = "https://api.mailgun.net/v3/\(mailgunDomain)/messages" + let auth:String = "api:\(mailgunAPIKey)".data(using: .utf8)!.base64EncodedString() + var request = HTTPClientRequest(url: targetURL) + request.method = .POST + request.headers.add(name: "Content-Type", value: "application/x-www-form-urlencoded") + request.headers.add(name: "Authorization", value: "Basic \(auth)") + + let bodyString: String = "from=\(fromEmailAddress)&to=\(messageRequest.recipient)&subject=\(messageRequest.subject ?? "")&text=\(messageRequest.content)" + request.body = .bytes(ByteBuffer(bytes: Data(bodyString.utf8))) + do{ + let response = try await httpClient.execute(request, timeout: .seconds(30)) + if response.status == .ok { + return nil + } else { + let responseBody: ByteBuffer = try await response.body.collect(upTo: 1024 * 1024) // 1 MB + //Some errors, normally ones with authorization, return strings and other errors return json. This do catch handles both cases + do{ + let responseBodyJson = try JSONSerialization.jsonObject(with: responseBody.getData(at: 0, length: responseBody.readableBytes)!, options: []) + return MessengerError.validationError(error: "Unable to send email, API Status Code: \(response.status), API Error Message: \((responseBodyJson as! [String: Any])["message"] ?? "none")") + }catch{ + let responseBodyString = String(data: Data(buffer: responseBody), encoding: .utf8) ?? "Failed to convert data to UTF-8 string" + return MessengerError.validationError(error: "Unable to send email, API Status Code: \(response.status), API Error Message: \(responseBodyString)") + } + } + }catch{ + return MessengerError.providerError(error: "Request url not responding or connection timed out") + } + } +} \ No newline at end of file diff --git a/swift/send-message/Sources/Messenger.swift b/swift/send-message/Sources/Messenger.swift new file mode 100644 index 00000000..0e668665 --- /dev/null +++ b/swift/send-message/Sources/Messenger.swift @@ -0,0 +1,86 @@ +import Foundation +import AsyncHTTPClient +//an enum of the implementations we provide, anything else is rejected +enum MessageType: String { + case sms = "sms" + case email = "email" + case twitter = "twitter" + case discord = "discord" + case none +} + +//error types which we want to define, handy for making sure all implementations provide the same errors +enum MessengerError: Error { + case validationError(error: String) //the message request + case providerError(error: String) //what if things appear in good order to us but discord is down? This error type signifies that there is a downstream error. + case misconfigurationError(error: String)// something is wrong with config like the API token for discord being wrong etc +} + +//this is our interface for users to interact with us, input to this example function must conform to this +struct Message { + var type: MessageType + var recipient: String + var content: String + var subject: String? +} + +protocol Messenger { + //make sure this is synchronous and you handle error handling + func sendMessage(messageRequest: Message) async -> Error? +} + +func main(req: RequestValue, res: RequestResponse) async throws -> RequestResponse { + + guard !req.payload.isEmpty, + let data = req.payload.data(using: .utf8), + let payload: [String: Any?] = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let typeString = payload["type"] as? String, + let recipient = payload["recipient"] as? String, + let content = payload["content"] as? String else { + return res.json(data: ["success": false, "message" :"Misconfigurtion Error: Invalid payload."]) + } + + let type:MessageType = MessageType(rawValue: typeString) ?? .none + let messenger: Messenger + let httpClient = HTTPClient(eventLoopGroupProvider: .createNew) + + //initialize messenger with environment variables and httpClient + do{ + switch type{ + case .sms: + messenger = try SMSMessenger(req.variables, httpClient: httpClient) + case .email: + messenger = try EmailMessenger(req.variables, httpClient: httpClient) + case .twitter: + messenger = try TwitterMessenger(req.variables, httpClient: httpClient) + case .discord: + messenger = try DiscordMessenger(req.variables, httpClient: httpClient) + default: + return res.json(data: ["success": false, "message" :"Misconfigurtion Error: Invalid Type"]) + } + } catch{ + return res.json(data: ["success": false, "message" :"Misconfigurtion Error: Missing environment variables."]) + } + + let subject = payload["subject"] as? String + let messageRequest = Message(type: type, recipient: recipient, content: content, subject: subject) + + let result = await messenger.sendMessage(messageRequest: messageRequest) + try await httpClient.shutdown() + + if result == nil{ + return res.json(data: ["success": true]) + } else { + let messengerError = result as? MessengerError + switch messengerError{ + case let .validationError(error): + return res.json(data: ["success": false, "message" :"Validation Error: \(error)"]) + case let .providerError(error): + return res.json(data: ["success": false, "message" :"Provider Error: \(error)"]) + case let .misconfigurationError(error): + return res.json(data: ["success": false, "message" :"Misconfigurtion Error: \(error)"]) + default: + return res.json(data: ["success": false, "message" :"Unknown Error"]) + } + } +} \ No newline at end of file diff --git a/swift/send-message/Sources/SMSMessenger.swift b/swift/send-message/Sources/SMSMessenger.swift new file mode 100644 index 00000000..7a5b5951 --- /dev/null +++ b/swift/send-message/Sources/SMSMessenger.swift @@ -0,0 +1,67 @@ +import AsyncHTTPClient +import Foundation +import NIO +import NIOFoundationCompat + +class SMSMessenger: Messenger{ + private let authToken: String + private let accountSid: String + private let twilioNumber: String + private let httpClient: HTTPClient + + init(_ env_vars: [String: String], httpClient: HTTPClient) throws{ + guard let accountSid = env_vars["TWILIO_ACCOUNT_SID"], + let authToken = env_vars["TWILIO_AUTH_TOKEN"], + let twilioNumber = env_vars["TWILIO_SENDER"] else { + throw MessengerError.misconfigurationError(error: "Missing environment variables.") + } + self.authToken = authToken + self.accountSid = accountSid + self.twilioNumber = twilioNumber + self.httpClient = httpClient + + } + /* + Creates an HTTPClientRequest to send an SMS message via Twilio API. + */ + private func createRequest(message: Message) async throws-> HTTPClientRequest { + let targetURL: String = "https://api.twilio.com/2010-04-01/Accounts/\(accountSid)/Messages" + let credentials: String = "\(accountSid):\(authToken)" + var request = HTTPClientRequest(url: targetURL) + request.method = .POST + request.headers.add(name: "Content-Type", value: "application/x-www-form-urlencoded") + request.headers.add(name:"Authorization", value: "Basic \(Data(credentials.utf8).base64EncodedString())") // credentials + let bodyString: String = "From=\(twilioNumber)&Body=\(message.content)&To=\(message.recipient)" + let bodyData = Data(bodyString.utf8) + request.body = .bytes(ByteBuffer(bytes: bodyData)) + return request + } + /* + Sends an SMS message using the Twilio API and handles potential errors during the process. + */ + public func sendMessage(messageRequest: Message) async -> Error?{ + + do{ + let request = try await createRequest(message: messageRequest) + let response: HTTPClientResponse = try await httpClient.execute(request, timeout: .seconds(30)) + //messege created successfully + switch response.status.code{ + case 201: + return nil + case 400: + return MessengerError.misconfigurationError(error: "Bad request in request construction") + case 401: + return MessengerError.validationError(error: "Unauthorized credentials, Ensure credentials are correct") + case 404: + return MessengerError.providerError(error: "Message couldnt be found") + case 500,503,429: + return MessengerError.providerError(error: "Connection timed out, try again later") + default: + return MessengerError.providerError(error: "Error: \(response.status)") + } + } + catch{ + return MessengerError.providerError(error: "Connection error: \(error)") + } + } +} diff --git a/swift/send-message/Sources/TwitterMessenger.swift b/swift/send-message/Sources/TwitterMessenger.swift new file mode 100644 index 00000000..49d7eec9 --- /dev/null +++ b/swift/send-message/Sources/TwitterMessenger.swift @@ -0,0 +1,93 @@ +import Foundation +import AsyncHTTPClient +import NIO +import NIOFoundationCompat +import Crypto + +class TwitterMessenger : Messenger{ + private let oauth_consumer_key:String + private let oauth_consumer_secret:String + private let oauth_token:String + private let oauth_token_secret:String + private let httpClient: HTTPClient + + init(_ env_vars: [String: String], httpClient: HTTPClient) throws { + guard let oauth_consumer_key = env_vars["TWITTER_API_KEY"], + let oauth_consumer_secret = env_vars["TWITTER_API_KEY_SECRET"], + let oauth_token = env_vars["TWITTER_ACCESS_TOKEN"], + let oauth_token_secret = env_vars["TWITTER_ACCESS_TOKEN_SECRET"] else { + throw MessengerError.misconfigurationError(error: "Missing environment variables.") + } + self.oauth_consumer_key = oauth_consumer_key + self.oauth_consumer_secret = oauth_consumer_secret + self.oauth_token = oauth_token + self.oauth_token_secret = oauth_token_secret + self.httpClient = httpClient + } + + func sendMessage(messageRequest:Message) async -> Error? { + /* + - If recipient is specified, recipient is tagged and tweet becomes a reply + - If no recipient specified, tweet is standard tweet + */ + var tweetText:String + if messageRequest.recipient != "" { + tweetText = "@" + messageRequest.recipient + " " + messageRequest.content + } else { + tweetText = messageRequest.content + } + let jsonText : [String:String] = ["text":"\(tweetText)"] + + let requestURL = "https://api.twitter.com/2/tweets" + var request = HTTPClientRequest(url: requestURL) + request.method = .POST + request.headers.add(name: "Content-Type", value: "application/json") + request.headers.add(name: "Authorization", value: createAuthHeader()) + + do { + request.body = .bytes(ByteBuffer(data: (try JSONSerialization.data(withJSONObject: jsonText)))) + let response = try await httpClient.execute(request, timeout: .seconds(30)) + + if response.status.code != 201 { + var errorMessage:String = "" + do { + let bodyResponse: ByteBuffer = try await response.body.collect(upTo: 1024*1024) + let jsonResponse = try JSONSerialization.jsonObject(with: bodyResponse.getData(at:0, length: bodyResponse.readableBytes)!, options: []) + if let jsonResponseDict = jsonResponse as? [String: Any], + let detail = jsonResponseDict["detail"] { + errorMessage = detail as! String + } + return MessengerError.validationError(error: "Unable to post tweet, API Status Code: \(response.status), API Error Message: \(errorMessage)") + } catch { + return error + } + } + } catch { + return MessengerError.providerError(error: "Request did not recieve a response or connection timeout") + } + return nil //Returns no error if tweet was created + } + + private func createAuthHeader() -> String { + let nonce = (Data(ChaChaPoly.Nonce()).base64EncodedString()).filter{$0.isLetter} + let timestamp = Int(Date().timeIntervalSince1970) + let customAllowedSet = NSCharacterSet(charactersIn:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") as CharacterSet + var baseString = "POST&https%3A%2F%2Fapi.twitter.com%2F2%2Ftweets&" + let parameters = """ + oauth_consumer_key=\(oauth_consumer_key)&oauth_nonce=\(nonce)&oauth_signature_method=HMAC-SHA1&\ + oauth_timestamp=\(timestamp)&oauth_token=\(oauth_token)&oauth_version=1.0 + """ + baseString.append(contentsOf: parameters.addingPercentEncoding(withAllowedCharacters: customAllowedSet)!) + let dataBaseString = baseString.data(using: .utf8)! + let signingKey = (oauth_consumer_secret + "&" + oauth_token_secret).data(using: .utf8)! + + let hmacHash = HMAC.authenticationCode(for: dataBaseString, using: SymmetricKey(data: signingKey)) + let signature = Data(hmacHash).base64EncodedString().addingPercentEncoding(withAllowedCharacters: customAllowedSet)! + + return """ + OAuth oauth_consumer_key=\"\(oauth_consumer_key)\",oauth_token=\"\(oauth_token)\",oauth_signature_method=\"HMAC-SHA1\",\ + oauth_timestamp=\"\(timestamp)\",oauth_nonce=\"\(nonce)\",\ + oauth_version=\"1.0\",oauth_signature=\"\(signature)\" + """ + } +} \ No newline at end of file