Skip to content

Commit 9f7c462

Browse files
committed
Posting automation
1 parent 3e025b7 commit 9f7c462

5 files changed

Lines changed: 364 additions & 12 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,29 @@ bin/bridgetown console
5151

5252
> Learn more: [Bridgetown CLI Documentation](https://www.bridgetownrb.com/docs/command-line-usage)
5353
54+
## Managing Events & Social Media
55+
56+
We use a custom CLI script (`bin/event`) to manage meetup posts and automatically publish them to our social media channels (Mastodon and Bluesky).
57+
58+
**1. Create a new event**
59+
```sh
60+
bin/event new 2026-08-20 "Intro to Hotwire"
61+
```
62+
This generates a new markdown post in `src/_posts/` with the correct boilerplate front matter. You can then edit the file to add details like the `meetup_link`.
63+
64+
**2. Publish and announce on social media**
65+
```sh
66+
bin/event publish
67+
```
68+
This launches an interactive wizard that lets you:
69+
- Select an event from a reverse-chronological list of all posts.
70+
- Choose a post template (Original Announcement, Reminder, or Post-Event Recap).
71+
- Edit the social media post text inline.
72+
- Automatically publish the post to **Mastodon** and **Bluesky** using credentials stored in 1Password.
73+
74+
**Threaded Replies**:
75+
The first time you publish an event, `bin/event publish` will save the generated Mastodon and Bluesky URLs directly into the post's markdown front matter. If you run `bin/event publish` again for the *same* event (e.g. to send a Reminder or Post-Event recap), it will automatically fetch those URLs and post your new updates as **threaded replies** to the original post on both platforms!
76+
5477
## Deployment
5578

5679
You can deploy Bridgetown sites on hosts like statichost.eu and Render as well as traditional web servers by simply building and copying the output folder to your HTML root.

bin/event

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
require "date"
55
require "fileutils"
6+
require_relative "../lib/publishers/publisher"
7+
require_relative "../lib/publishers/mastodon_publisher"
8+
require_relative "../lib/publishers/bluesky_publisher"
9+
10+
PUBLISHERS = [
11+
MastodonPublisher.new,
12+
BlueskyPublisher.new
13+
].freeze
614

715
def usage
816
puts <<~USAGE
@@ -63,25 +71,144 @@ def cmd_new(args)
6371
puts "Created new event post at #{filename}"
6472
end
6573

66-
def cmd_publish(args)
67-
puts "Staging changes..."
68-
unless system("git add src/_posts/")
69-
puts "Failed to add changes to git."
70-
exit 1
74+
def parse_front_matter(content)
75+
match = content.match(/\A---\n(.*?\n)---\n(.*)\z/m)
76+
return [{}, content] unless match
77+
fm = {}
78+
match[1].each_line do |line|
79+
if line =~ /\A(\w[\w_]*):\s*(.*)\s*\z/
80+
key = $1
81+
val = $2.strip
82+
val = val[1..-2] if val.start_with?('"') && val.end_with?('"')
83+
fm[key] = val
84+
end
7185
end
86+
[fm, match[2]]
87+
end
7288

73-
puts "Committing changes..."
74-
unless system("git commit -m 'Add new event post'")
75-
puts "No new changes to commit, or commit failed."
89+
def update_front_matter_field(content, key, value)
90+
if content =~ /^#{Regexp.escape(key)}:/
91+
content.sub(/^#{Regexp.escape(key)}:.*$/, "#{key}: \"#{value}\"")
92+
else
93+
content.sub(/\A(---\n.*?)^(---\n)/m, "\\1#{key}: \"#{value}\"\n\\2")
7694
end
95+
end
96+
97+
def cmd_publish(args)
98+
require "readline"
99+
100+
posts = Dir.glob("src/_posts/*.md").map do |path|
101+
content = File.read(path)
102+
fm, _ = parse_front_matter(content)
103+
{ path: path, fm: fm, content: content }
104+
end
105+
106+
posts.sort_by! { |p| p[:fm]["event_date"] || File.basename(p[:path]) }.reverse!
77107

78-
puts "Pushing to remote to trigger deployment..."
79-
unless system("git push")
80-
puts "Failed to push to remote."
108+
puts "Select an event to post about:"
109+
posts.each_with_index do |p, i|
110+
published = p[:fm]["mastodon_post_url"] ? "[Published]" : "[Unpublished]"
111+
puts " #{i + 1}) #{p[:fm]['event_date']} - #{p[:fm]['title']} #{published}"
112+
end
113+
print "Choice: "
114+
choice = $stdin.gets.strip.to_i
115+
if choice < 1 || choice > posts.length
116+
puts "Invalid choice."
117+
exit 1
118+
end
119+
120+
selected = posts[choice - 1]
121+
122+
title = selected[:fm]["title"]
123+
date = selected[:fm]["event_date"]
124+
basename = File.basename(selected[:path], ".md")
125+
if basename =~ /^(\d{4})-(\d{2})-(\d{2})-(.+)$/
126+
website_url = "https://rubyaz.org/#{$1}/#{$2}/#{$3}/#{$4}/"
127+
else
128+
website_url = "https://rubyaz.org/"
129+
end
130+
131+
puts "\nSelect post type:"
132+
puts " 1) Original (Announce the event)"
133+
puts " 2) Reminder (A few days before)"
134+
puts " 3) Post-Event (Thanks and recap)"
135+
print "Choice: "
136+
type_choice = $stdin.gets.strip
137+
138+
case type_choice
139+
when "1"
140+
default_text = "Join us for #{title} on #{date}!\n\nRSVP & Details: #{website_url}\n\n#ruby #rubyaz"
141+
when "2"
142+
default_text = "Reminder! We're meeting for #{title} on #{date}.\n\nSee you there: #{website_url}\n\n#ruby #rubyaz"
143+
when "3"
144+
default_text = "Thanks to everyone who joined us for #{title}!\n\nCheck out the slides and recap: #{website_url}\n\n#ruby #rubyaz"
145+
else
146+
puts "Invalid choice."
81147
exit 1
82148
end
83149

84-
puts "Successfully published events!"
150+
# Replace escaped newlines so they render correctly in Readline
151+
default_text = default_text.gsub('\\n', "\n")
152+
153+
puts "\nEdit your post text (press Enter to accept):"
154+
155+
Readline.pre_input_hook = -> {
156+
Readline.insert_text(default_text)
157+
Readline.redisplay
158+
Readline.pre_input_hook = nil
159+
}
160+
161+
final_text = Readline.readline("> ", false)
162+
163+
puts "\nFinal Text:"
164+
puts "---"
165+
puts final_text
166+
puts "---"
167+
print "Publish this? (y/n): "
168+
unless $stdin.gets.strip.downcase == 'y'
169+
puts "Aborted."
170+
exit 0
171+
end
172+
173+
updates_made = false
174+
new_content = selected[:content]
175+
176+
PUBLISHERS.each do |publisher|
177+
existing_url = selected[:fm][publisher.front_matter_key]
178+
reply_to = existing_url && !existing_url.empty? ? existing_url : nil
179+
180+
if reply_to
181+
puts "Posting to #{publisher.name} as a reply to #{reply_to}..."
182+
else
183+
puts "Posting to #{publisher.name}..."
184+
end
185+
186+
result = publisher.post(final_text, reply_to: reply_to)
187+
188+
if result[:error]
189+
puts "FAILED"
190+
puts " #{result[:error]}"
191+
else
192+
puts "OK"
193+
puts " #{result[:url]}"
194+
unless reply_to
195+
new_content = update_front_matter_field(new_content, publisher.front_matter_key, result[:url])
196+
updates_made = true
197+
end
198+
end
199+
end
200+
201+
if updates_made
202+
File.write(selected[:path], new_content)
203+
puts "\nStaging changes..."
204+
system("git add src/_posts/")
205+
puts "Committing changes..."
206+
system("git commit -m 'Add social URLs to #{basename}'")
207+
puts "Pushing to remote to trigger deployment..."
208+
system("git push")
209+
end
210+
211+
puts "Done!"
85212
end
86213

87214
case ARGV[0]
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# frozen_string_literal: true
2+
3+
require "net/http"
4+
require "json"
5+
require "uri"
6+
7+
class BlueskyPublisher < Publisher
8+
# TODO: Fill in these 1Password reference URIs
9+
OP_HANDLE = "op://Ruby::AZ/Ruby::AZ Bluesky/Handle"
10+
OP_APP_PASSWORD = "op://Private/Ruby::AZ Bluesky/App_Password"
11+
API = "https://bsky.social"
12+
13+
def name = "Bluesky"
14+
def front_matter_key = "bluesky_post_url"
15+
16+
def post(text, reply_to: nil)
17+
handle = fetch_op_secret(OP_HANDLE)
18+
return { error: "Failed to retrieve Bluesky handle from 1Password at #{OP_HANDLE}" } unless handle
19+
20+
app_password = fetch_op_secret(OP_APP_PASSWORD)
21+
return { error: "Failed to retrieve Bluesky app password from 1Password at #{OP_APP_PASSWORD}" } unless app_password
22+
23+
session = create_session(handle, app_password)
24+
return session if session[:error]
25+
26+
facets = detect_facets(text)
27+
28+
record = {
29+
"$type" => "app.bsky.feed.post",
30+
"text" => text,
31+
"createdAt" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"),
32+
}
33+
record["facets"] = facets unless facets.empty?
34+
35+
if reply_to
36+
cid = fetch_cid(session, reply_to)
37+
if cid
38+
record["reply"] = {
39+
"root" => { "uri" => reply_to, "cid" => cid },
40+
"parent" => { "uri" => reply_to, "cid" => cid }
41+
}
42+
end
43+
end
44+
45+
body = {
46+
"repo" => session[:did],
47+
"collection" => "app.bsky.feed.post",
48+
"record" => record,
49+
}
50+
51+
uri = URI("#{API}/xrpc/com.atproto.repo.createRecord")
52+
response = https_post_json(uri, body, auth: session[:access_jwt])
53+
54+
unless response.is_a?(Net::HTTPSuccess)
55+
return { error: "Bluesky API error: #{response.code} #{response.body}" }
56+
end
57+
58+
data = JSON.parse(response.body)
59+
rkey = data["uri"].split("/").last
60+
{ id: data["uri"], url: "https://bsky.app/profile/#{handle}/post/#{rkey}" }
61+
end
62+
63+
private
64+
65+
def create_session(handle, app_password)
66+
uri = URI("#{API}/xrpc/com.atproto.server.createSession")
67+
body = { "identifier" => handle, "password" => app_password }
68+
response = https_post_json(uri, body)
69+
70+
unless response.is_a?(Net::HTTPSuccess)
71+
return { error: "Bluesky auth failed: #{response.code} #{response.body}" }
72+
end
73+
74+
data = JSON.parse(response.body)
75+
{ did: data["did"], access_jwt: data["accessJwt"] }
76+
end
77+
78+
def detect_facets(text)
79+
facets = []
80+
81+
# URLs
82+
text.scan(/(https?:\/\/[^\s)]+)/) do
83+
match = Regexp.last_match
84+
byte_start = text[0...match.begin(0)].bytesize
85+
byte_end = byte_start + match[0].bytesize
86+
facets << {
87+
"index" => { "byteStart" => byte_start, "byteEnd" => byte_end },
88+
"features" => [{ "$type" => "app.bsky.richtext.facet#link", "uri" => match[0] }],
89+
}
90+
end
91+
92+
# Hashtags
93+
text.scan(/(?<=\s|^)#(\w+)/) do
94+
match = Regexp.last_match
95+
tag_with_hash = "##{match[1]}"
96+
byte_start = text[0...match.begin(0)].bytesize
97+
byte_end = byte_start + tag_with_hash.bytesize
98+
facets << {
99+
"index" => { "byteStart" => byte_start, "byteEnd" => byte_end },
100+
"features" => [{ "$type" => "app.bsky.richtext.facet#tag", "tag" => match[1] }],
101+
}
102+
end
103+
104+
facets
105+
end
106+
107+
def https_post_json(uri, body, auth: nil)
108+
http = Net::HTTP.new(uri.host, uri.port)
109+
http.use_ssl = true
110+
111+
request = Net::HTTP::Post.new(uri)
112+
request["Content-Type"] = "application/json"
113+
request["Authorization"] = "Bearer #{auth}" if auth
114+
request.body = JSON.generate(body)
115+
116+
http.request(request)
117+
end
118+
119+
def fetch_cid(session, uri)
120+
parts = uri.sub("at://", "").split("/")
121+
repo = parts[0]
122+
collection = parts[1]
123+
rkey = parts[2]
124+
125+
get_uri = URI("#{API}/xrpc/com.atproto.repo.getRecord?repo=#{repo}&collection=#{collection}&rkey=#{rkey}")
126+
req = Net::HTTP::Get.new(get_uri)
127+
req["Authorization"] = "Bearer #{session[:access_jwt]}"
128+
129+
http = Net::HTTP.new(get_uri.host, get_uri.port)
130+
http.use_ssl = true
131+
res = http.request(req)
132+
133+
return nil unless res.is_a?(Net::HTTPSuccess)
134+
JSON.parse(res.body)["cid"]
135+
end
136+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
require "net/http"
4+
require "json"
5+
require "uri"
6+
7+
class MastodonPublisher < Publisher
8+
# TODO: Fill in these 1Password reference URIs
9+
OP_TOKEN = "op://Ruby::AZ/Ruby::AZ Mastodon/Access_Token"
10+
INSTANCE = "https://ruby.social"
11+
12+
def name = "Mastodon"
13+
def front_matter_key = "mastodon_post_url"
14+
15+
def post(text, reply_to: nil)
16+
token = fetch_op_secret(OP_TOKEN)
17+
return { error: "Failed to retrieve Mastodon token from 1Password at #{OP_TOKEN}" } unless token
18+
19+
uri = URI("#{INSTANCE}/api/v1/statuses")
20+
http = Net::HTTP.new(uri.host, uri.port)
21+
http.use_ssl = uri.scheme == "https"
22+
23+
request = Net::HTTP::Post.new(uri)
24+
request["Authorization"] = "Bearer #{token}"
25+
26+
if reply_to && reply_to.include?("/")
27+
reply_to = reply_to.split("/").last
28+
end
29+
30+
data = { "status" => text }
31+
data["in_reply_to_id"] = reply_to if reply_to
32+
request.set_form_data(data)
33+
34+
response = http.request(request)
35+
unless response.is_a?(Net::HTTPSuccess)
36+
return { error: "Mastodon API error: #{response.code} #{response.body}" }
37+
end
38+
39+
data = JSON.parse(response.body)
40+
{ id: data["id"], url: data["url"] }
41+
end
42+
end

0 commit comments

Comments
 (0)