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:
- You have an application token
- If using VSCODE extension
sswg.swift-lang
has been installed - Swift 5.7 is installed on the machine (check with
swift --version
) - A gitignore with the items in
gitignore_example.txt
(.env
!!!)
Writing a New Bot:
If you’d like to start from scratch, here are the steps
- 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.
- Update the
Package.swift
to includeArgumentParser
andTrunkLine
or some other MastodonAPI library. See this projectsPackage.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
- Note that the
swift package init
command created two directories (Sources/your_bot_project_name
) and made a file calledyour_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
- Swift Command Line with Argument Parser
- https://www.swift.org/blog/argument-parser/
- https://swiftpackageindex.com/apple/swift-argument-parser/1.2.1/documentation/argumentparser/gettingstarted
- https://github.com/carlynorama/tipsy-robot
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!