Well clearly I need bot minions first

So Twitter announced the demise of the bot API so clearly it’s time to make bots for Mastodon. I’m not super comfortable on social media. This is maybe the way for me.

It look ~20 minutes to do hello-mastodon-bot in JavaScript, how long can it possibly take in Swift?

Well, not long as well if you’re smart enough to use a library, I’m guessing. I am not that smart.

One week later and I am still not done.

Steps so far

Package things up

First I split ActivityPubExplorer into a Mastodon Client called TrunkLine and a generic API Client APItizer

Just getting a new testing app up and running back to where ActivityPubExplorer with the new packages took some time.

These libraries at that point are read-only protocols no authorizations.

Start a CLI Project

Then it was getting to Hello-World in a CLI. Here are the notes from the tispy-robot-swift repo README as of today.

Assumptions:

Writing a New Bot:

If you’d like to start from scratch, here are the steps

  1. Run the following commands to get the bot executable started and verify that it can build:

    mkdir your_bot_project_name
    cd your_bot_project_name
    swift package init --type executable
    swift run                                   #will see Hello, world! in console. 
  1. Update the Package.swift to include ArgumentParser and TrunkLine or some other MastodonAPI library. See this projects Package.swift.

Since TrunkLine requires .macOS(.v12) for now, so does this example.

Also note that the reference to TrunkLine references a branch instead of a version number because it is my library and it’s under development in tandem with this project. If working on a library at the same time as using it in a project swift package update forces your project to go fetch the newest version. If that still is not enough, delete the .build folder, but that may be an indicator

  1. Note that the swift package init command created two directories (Sources/your_bot_project_name) and made a file called your_bot_project_name.swift in that file is a function that looks like:

    @main
    public struct your_bot_project_name {
        public private(set) var text = "Hello, World!"

        public static func main() {
            print(your_bot_project_name().text)
        }
    }

Some people change this and just have a main.swift file in the Sources/your_bot_project_name directory which then is the contents of their @main function.

Change the contents of that file to match the contents of hello_server_example.swift and try swift run. Your bot should have posted!

References & Resources

Getting the token into the environment

To use a bot or any client app to post, there needs to be Authentication & Authorization. Once you have a token, it’s a mistake to keep it in the repo and a pain to keep copying it.

Here is the simplest code to make that work. In the APItizer package it works with the Keychain as well now.


//---------------------------------------------- C getenv & setenv work!
func getEnvironmentVar(_ name: String) -> String? {
    guard let rawValue = getenv(name) else { return nil }
    return String(utf8String: rawValue)
}
func setEnvironment(key:String, value:String, overwrite: Bool = false) {
    setenv(key, value, overwrite ? 1 : 0)
}

setEnvironment(key: "SECRET1", value: "(whispers I cant hear)", overwrite: true)
print(getEnvironmentVar("SECRET1") ?? "nothing")

//-------------------------------------------------- mini dotEnv handling
func loadDotEnv() throws {

    let url = URL(fileURLWithPath: ".env")
    guard let envString = try? String(contentsOf: url) else {
       fatalError("no env file data")
    }

    envString
        .trimmingCharacters(in: .newlines)
        .split(separator: "\n")
        .lazy //may or maynot save anything
        .filter({$0.prefix(1) != "#"})  //is comment
        .map({ $0.split(separator: "=").map({String($0.trimmingCharacters(in: CharacterSet(charactersIn:"\"\'")))}) })
        .forEach({  addToEnv(result: $0) })

    func addToEnv(result:Array<String>) {
        if result.count == 2  {
            setEnvironment(key: result[0], value: result[1], overwrite: true)
        } else {
            //item would of had to have contained more than 1 "=" or none at all. I'd like to know about that for now. 
            print("Failed dotenv add: \(result)") 
        }
    }
}

do {
    try loadDotEnv()
    
    if let value = ProcessInfo.processInfo.environment["SECRET"] {
        print(value)
    } else {
        print("I don't know the secret.")
    }

    if let anotherValue = ProcessInfo.processInfo.environment["ANOTHER"] {
        print(anotherValue)
    } else {
        print("I don't know the other secret.")
    }
}
catch {
    print("\(error)") //handle or silence the error here
}

Posting Hello

Getting just a simple status to post meant not simply adding URLQueryItems to the endpoint but doing “URLEncoded” data and adding the token to the HTTP Header. Thankfully that is the urlsession.upload() default.

    //using async upload, see APing for manual body example.
    func post_URLEncoded(baseUrl:URL, formData:Dictionary<String, CustomStringConvertible>, withAuth:Bool = true) async throws -> Data {
        //cachePolicy: URLRequest.CachePolicy, timeoutInterval: TimeInterval
        var request = URLRequest(url: baseUrl)
        request.httpMethod = "POST"
        if withAuth { try addAuth(request: &request) }

        let dataToSend = try URLEncoder.makeURLEncodedString(formItems: formData).data(using: .utf8)

        //Uneeded b/c this appears to be the default.
        //request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
        
        return try await requestService.postData(urlRequest: request, data: dataToSend!)

    }
    
    //using async upload, see APing for manual body example.
    func post_URLEncoded(baseUrl:URL, dataToSend:Data, withAuth:Bool = true) async throws -> Data {
        //cachePolicy: URLRequest.CachePolicy, timeoutInterval: TimeInterval
        var request = URLRequest(url: baseUrl)
        request.httpMethod = "POST"
        if withAuth { try addAuth(request: &request) }
        
        return try await requestService.postData(urlRequest: request, data: dataToSend)

    }

Adding auth:

    //In public extension on APIService where Self:Authorizable
    func addAuth(request: inout URLRequest) throws {
        //print(self)
        //might also consider checking validation. 
        //print("addAuth: hasValidToken \(self.hasValidToken)")
        if let auth = self.authentication {
            try auth.addBearerToken(to:&request)
        } else {
            throw AuthorizableError("No authorizations defined.")
        }
    }

    //in Authentication
    func addBearerToken(to request: inout URLRequest) throws {
        request.setValue("Bearer \(try fetchToken())", forHTTPHeaderField: "Authorization")
    }

Over in request service:

        public func postData(urlRequest:URLRequest, data:Data) async throws -> Data {
        let (responseData, response) = try await session.upload(for: urlRequest, from: data, delegate: nil)        
        guard let httpResponse = response as? HTTPURLResponse else {
            print(response)
            throw HTTPRequestServiceError("Not an HTTP Response.")
        }
         guard (200...299).contains(httpResponse.statusCode)  else  {
             print(response)
             throw HTTPRequestServiceError("Request Failed:\(httpResponse.statusCode), \(String(describing:httpResponse.mimeType))")
         }
        return responseData
    }

Some encoding helpers.

        public static func makeQueryItems(from itemToEncode:Encodable) -> [URLQueryItem] {
        let encoder = JSONEncoder()
        func encode<T>(_ value: T) throws -> [String: Any] where T : Encodable {
            let data = try encoder.encode(value)
            return try JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any]
        }
        guard let dictionary = try? encode(itemToEncode) else {
            //print("got nothing")
            return []
        }
        var queries:[URLQueryItem] = []
        for (key, value) in dictionary {
            var stringValue = "\(value)"
            if stringValue == "(\n)" { stringValue = "" }
            if stringValue.hasPrefix("(") { stringValue = stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "()")).removingCharacters(in: .whitespacesAndNewlines)}
            //print(stringValue)
            if !stringValue.isEmpty  {
                queries.append(URLQueryItem(name: key, value: "\(stringValue)"))
            }
        }
        
        return queries
    }

    public static func makeURLEncodedString(queryItems:[URLQueryItem]) throws -> String {
        queryItems.map(urlEncode).joined(separator: "&")
    }
    
    static private func urlEncode(_ queryItem: URLQueryItem) -> String {
        let name = urlEncode(queryItem.name)
        let value = urlEncode(queryItem.value ?? "")
        return "\(name)=\(value)"
    }
    
    static private func urlEncode(_ string: String) -> String {
        string.addingPercentEncoding(withAllowedCharacters: CharacterSet.alphanumerics) ?? ""
    }

Finally… actually Posting on Mastodon

And now we have the very short “Hello World” posting function

    public func newPost(message:String) async throws {
        let path:String? = actions["new_status"]?.endPointPath
        let url = try urlFrom(path: path!, prependBasePath: true)
        
        let returnData = try await post_URLEncoded(baseUrl:url, formData:["status":message], withAuth:true)
        
        print(String(data: returnData, encoding: .utf8)!)
    }

This was a a bunch to get done, and I STILL can’t post an image!