Skip to content

Commit 3607b1c

Browse files
brunoborgesCopilot
andcommitted
feat: add automated weekly Twitter posting for Java patterns
- Queue generator (generatesocialqueue.java): shuffles all 113 patterns, pre-drafts tweets into social/tweets.yaml, validates 280-char limit - Post script (socialpost.java): OAuth 1.0a signing via Java HttpClient, dry-run support, state tracking after confirmed API success - GitHub Actions workflow: every Monday 14:00 UTC with manual dispatch, concurrency guard, commits state back to repo - Social state lives in social/ (not content/) to avoid triggering deploys - Updated spec and secrets documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1f6fc9f commit 3607b1c

File tree

8 files changed

+1797
-28
lines changed

8 files changed

+1797
-28
lines changed

.github/workflows/social-post.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Weekly Social Post
2+
3+
on:
4+
schedule:
5+
- cron: '0 14 * * 1' # Every Monday at 14:00 UTC (10 AM ET)
6+
workflow_dispatch: # Manual trigger
7+
8+
permissions:
9+
contents: write
10+
11+
concurrency:
12+
group: social-post
13+
cancel-in-progress: false
14+
15+
jobs:
16+
post:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v6
20+
21+
- uses: actions/setup-java@v5
22+
with:
23+
distribution: 'temurin'
24+
java-version: '25'
25+
26+
- uses: jbangdev/setup-jbang@main
27+
28+
- name: Post to Twitter
29+
env:
30+
TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }}
31+
TWITTER_CONSUMER_KEY_SECRET: ${{ secrets.TWITTER_CONSUMER_KEY_SECRET }}
32+
TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
33+
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
34+
run: jbang html-generators/socialpost.java
35+
36+
- name: Commit updated state
37+
run: |
38+
git config user.name "github-actions[bot]"
39+
git config user.email "github-actions[bot]@users.noreply.github.com"
40+
git add social/state.yaml social/queue.txt social/tweets.yaml
41+
git diff --cached --quiet && exit 0
42+
git commit -m "chore: update social post state [skip ci]"
43+
git pull --rebase
44+
git push
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
///usr/bin/env jbang "$0" "$@" ; exit $?
2+
//JAVA 25
3+
//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3
4+
//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3
5+
6+
import module java.base;
7+
import com.fasterxml.jackson.databind.*;
8+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
9+
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
10+
11+
/**
12+
* Generate social media queue and pre-drafted tweets from content YAML files.
13+
*
14+
* Produces:
15+
* social/queue.txt — shuffled posting order (one category/slug per line)
16+
* social/tweets.yaml — pre-drafted tweet text for each pattern
17+
*
18+
* Re-run behavior:
19+
* - New patterns are appended to the end of the existing queue
20+
* - Deleted/renamed patterns are pruned
21+
* - Existing order and tweet edits are preserved
22+
* - Use --reshuffle to force a full reshuffle
23+
*/
24+
25+
static final String CONTENT_DIR = "content";
26+
static final String SOCIAL_DIR = "social";
27+
static final String QUEUE_FILE = SOCIAL_DIR + "/queue.txt";
28+
static final String TWEETS_FILE = SOCIAL_DIR + "/tweets.yaml";
29+
static final String STATE_FILE = SOCIAL_DIR + "/state.yaml";
30+
static final String BASE_URL = "https://javaevolved.github.io";
31+
static final int MAX_TWEET_LENGTH = 280;
32+
33+
static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory());
34+
static final ObjectMapper YAML_WRITER = new ObjectMapper(
35+
new YAMLFactory()
36+
.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)
37+
.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES)
38+
);
39+
40+
record PatternInfo(String category, String slug, String title, String summary,
41+
String oldApproach, String modernApproach, String jdkVersion) {
42+
String key() { return category + "/" + slug; }
43+
}
44+
45+
void main(String... args) throws Exception {
46+
boolean reshuffle = List.of(args).contains("--reshuffle");
47+
48+
// 1. Scan all content files
49+
var allPatterns = scanContentFiles();
50+
System.out.println("Found " + allPatterns.size() + " patterns in content/");
51+
52+
// 2. Load existing queue and tweets (if any)
53+
var existingQueue = loadExistingQueue();
54+
var existingTweets = loadExistingTweets();
55+
56+
// 3. Determine new queue order
57+
List<String> queue;
58+
if (reshuffle || existingQueue.isEmpty()) {
59+
// Full shuffle
60+
var keys = new ArrayList<>(allPatterns.keySet());
61+
Collections.shuffle(keys);
62+
queue = keys;
63+
System.out.println(reshuffle ? "Reshuffled all patterns" : "Generated new queue");
64+
} else {
65+
// Preserve existing order, prune deleted, append new
66+
queue = new ArrayList<>();
67+
for (var key : existingQueue) {
68+
if (allPatterns.containsKey(key)) queue.add(key);
69+
else System.out.println(" Pruned (removed): " + key);
70+
}
71+
var existingSet = new LinkedHashSet<>(queue);
72+
var newKeys = new ArrayList<String>();
73+
for (var key : allPatterns.keySet()) {
74+
if (!existingSet.contains(key)) newKeys.add(key);
75+
}
76+
if (!newKeys.isEmpty()) {
77+
Collections.shuffle(newKeys);
78+
queue.addAll(newKeys);
79+
System.out.println(" Appended " + newKeys.size() + " new patterns: " + newKeys);
80+
}
81+
}
82+
83+
// 4. Generate tweet drafts
84+
var tweets = new LinkedHashMap<String, String>();
85+
int truncated = 0;
86+
for (var key : queue) {
87+
// Preserve manually edited tweets
88+
if (!reshuffle && existingTweets.containsKey(key)) {
89+
tweets.put(key, existingTweets.get(key));
90+
} else {
91+
var p = allPatterns.get(key);
92+
var tweet = buildTweet(p);
93+
tweets.put(key, tweet);
94+
if (tweet.length() > MAX_TWEET_LENGTH) {
95+
// Retry with truncated summary
96+
tweet = buildTweetTruncated(p);
97+
tweets.put(key, tweet);
98+
truncated++;
99+
}
100+
}
101+
}
102+
103+
// 5. Validate lengths
104+
int overLength = 0;
105+
for (var entry : tweets.entrySet()) {
106+
int len = entry.getValue().length();
107+
if (len > MAX_TWEET_LENGTH) {
108+
System.err.println(" WARNING: " + entry.getKey() + " tweet is " + len + " chars (max " + MAX_TWEET_LENGTH + ")");
109+
overLength++;
110+
}
111+
}
112+
113+
// 6. Write queue file
114+
Files.createDirectories(Path.of(SOCIAL_DIR));
115+
Files.writeString(Path.of(QUEUE_FILE), String.join("\n", queue) + "\n");
116+
System.out.println("Wrote " + QUEUE_FILE + " (" + queue.size() + " entries)");
117+
118+
// 7. Write tweets file
119+
YAML_WRITER.writerWithDefaultPrettyPrinter().writeValue(Path.of(TWEETS_FILE).toFile(), tweets);
120+
System.out.println("Wrote " + TWEETS_FILE + " (" + tweets.size() + " entries)");
121+
122+
// 8. Create state file if it doesn't exist
123+
if (!Files.exists(Path.of(STATE_FILE))) {
124+
var state = new LinkedHashMap<String, Object>();
125+
state.put("currentIndex", 1);
126+
state.put("lastPostedKey", null);
127+
state.put("lastTweetId", null);
128+
state.put("lastPostedAt", null);
129+
YAML_WRITER.writerWithDefaultPrettyPrinter().writeValue(Path.of(STATE_FILE).toFile(), state);
130+
System.out.println("Created " + STATE_FILE);
131+
}
132+
133+
if (truncated > 0) System.out.println(truncated + " tweets were truncated to fit 280 chars");
134+
if (overLength > 0) System.err.println("WARNING: " + overLength + " tweets still exceed 280 chars — edit manually in " + TWEETS_FILE);
135+
System.out.println("Done!");
136+
}
137+
138+
Map<String, PatternInfo> scanContentFiles() throws Exception {
139+
var patterns = new LinkedHashMap<String, PatternInfo>();
140+
var contentDir = Path.of(CONTENT_DIR);
141+
142+
try (var categories = Files.list(contentDir)) {
143+
for (var catDir : categories.filter(Files::isDirectory).sorted().toList()) {
144+
var category = catDir.getFileName().toString();
145+
try (var files = Files.list(catDir)) {
146+
for (var file : files.filter(f -> isContentFile(f)).sorted().toList()) {
147+
var node = YAML_MAPPER.readTree(file.toFile());
148+
var slug = node.path("slug").asText();
149+
var info = new PatternInfo(
150+
category, slug,
151+
node.path("title").asText(),
152+
node.path("summary").asText(),
153+
node.path("oldApproach").asText(),
154+
node.path("modernApproach").asText(),
155+
node.path("jdkVersion").asText()
156+
);
157+
patterns.put(info.key(), info);
158+
}
159+
}
160+
}
161+
}
162+
return patterns;
163+
}
164+
165+
boolean isContentFile(Path p) {
166+
var name = p.getFileName().toString();
167+
return name.endsWith(".yaml") || name.endsWith(".yml") || name.endsWith(".json");
168+
}
169+
170+
List<String> loadExistingQueue() throws Exception {
171+
var path = Path.of(QUEUE_FILE);
172+
if (!Files.exists(path)) return List.of();
173+
return Files.readAllLines(path).stream()
174+
.map(String::strip)
175+
.filter(s -> !s.isEmpty())
176+
.toList();
177+
}
178+
179+
@SuppressWarnings("unchecked")
180+
Map<String, String> loadExistingTweets() throws Exception {
181+
var path = Path.of(TWEETS_FILE);
182+
if (!Files.exists(path)) return Map.of();
183+
return YAML_MAPPER.readValue(path.toFile(), LinkedHashMap.class);
184+
}
185+
186+
String buildTweet(PatternInfo p) {
187+
return """
188+
☕ %s
189+
190+
%s
191+
192+
%s → %s (JDK %s+)
193+
194+
🔗 %s/%s/%s.html
195+
196+
#Java #JavaEvolved""".formatted(
197+
p.title(), p.summary(),
198+
p.oldApproach(), p.modernApproach(), p.jdkVersion(),
199+
BASE_URL, p.category(), p.slug()
200+
).stripIndent().strip();
201+
}
202+
203+
String buildTweetTruncated(PatternInfo p) {
204+
// Calculate budget: total minus everything except summary
205+
var template = """
206+
☕ %s
207+
208+
%s
209+
210+
%s → %s (JDK %s+)
211+
212+
🔗 %s/%s/%s.html
213+
214+
#Java #JavaEvolved""".stripIndent().strip();
215+
216+
var withoutSummary = template.formatted(
217+
p.title(), "",
218+
p.oldApproach(), p.modernApproach(), p.jdkVersion(),
219+
BASE_URL, p.category(), p.slug()
220+
);
221+
int budget = MAX_TWEET_LENGTH - withoutSummary.length();
222+
var summary = p.summary();
223+
if (summary.length() > budget && budget > 3) {
224+
summary = summary.substring(0, budget - 1) + "…";
225+
}
226+
return template.formatted(
227+
p.title(), summary,
228+
p.oldApproach(), p.modernApproach(), p.jdkVersion(),
229+
BASE_URL, p.category(), p.slug()
230+
);
231+
}

0 commit comments

Comments
 (0)