Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions lux/guides/twitter_automation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Twitter Automation Guide

Automated engagement, scheduling, auto-reply, and follow management for Twitter.

## Tweet Scheduler

```elixir
{:ok, pid} = TweetScheduler.start_link()

# Schedule a tweet
{:ok, entry} = TweetScheduler.schedule(pid, %{
text: "Hello Twitter!",
scheduled_at: ~U[2026-03-09 12:00:00Z]
})

# Schedule a thread
TweetScheduler.schedule(pid, %{
text: "Thread 1/3",
scheduled_at: ~U[2026-03-09 12:00:00Z],
thread: ["Thread 2/3", "Thread 3/3"]
})

# Get due tweets (ready to post)
{:ok, due} = TweetScheduler.get_due(pid)

# Content calendar
{:ok, calendar} = TweetScheduler.calendar(pid, {~D[2026-03-09], ~D[2026-03-15]})
```

## Engagement Rules

```elixir
{:ok, pid} = EngagementRules.start_link()

# Add rule: like crypto tweets from popular accounts
EngagementRules.add_rule(pid, %{
name: "Crypto engagement",
conditions: [{:keyword, ["bitcoin", "ethereum"]}, {:min_followers, 1000}],
actions: [:like, :retweet],
priority: 10
})

# Evaluate a tweet against all rules
{:ok, result} = EngagementRules.evaluate(pid, %{
text: "Bitcoin hits new ATH!",
author_followers: 50000,
id: "123"
})
# result.actions => [:like, :retweet]

# Rate limiting
EngagementRules.set_rate_limit(pid, :like, 50) # 50 likes per session
```

### Available Conditions
- `{:keyword, ["word1", "word2"]}` — text contains any keyword
- `{:min_followers, 1000}` — author has >= N followers
- `{:min_likes, 10}` — tweet has >= N likes
- `{:min_retweets, 5}` — tweet has >= N retweets
- `{:is_reply, false}` — is/isn't a reply
- `{:has_media, true}` — has media attachments
- `{:language, "en"}` — tweet language

## Auto-Reply

```elixir
{:ok, pid} = AutoReply.start_link(cooldown_seconds: 300)

# Add reply template
AutoReply.add_template(pid, %{
keywords: ["help", "support"],
reply_text: "Hi {{author}}, check our docs at https://docs.example.com!",
priority: 5
})

# Find matching reply for a tweet
{:ok, reply} = AutoReply.find_reply(pid, %{
text: "I need help with the API",
author_id: "user1",
author_name: "Alice"
})
# reply.reply_text => "Hi Alice, check our docs..."

# Block spammers
AutoReply.block_user(pid, "spammer_id")
```

## Follow Manager

```elixir
{:ok, pid} = FollowManager.start_link(daily_limit: 50)

# Queue follow/unfollow actions
FollowManager.queue_follow(pid, "user_id", :engagement_rule)
FollowManager.queue_unfollow(pid, "inactive_user", :inactive)

# Process queue
{:ok, pending} = FollowManager.get_pending(pid)
FollowManager.mark_done(pid, "user_id", :follow)

# Check stats
{:ok, stats} = FollowManager.stats(pid)
# %{following: 10, daily_count: 5, daily_limit: 50, ...}
```
127 changes: 127 additions & 0 deletions lux/lib/lux/prisms/twitter/auto_reply.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
defmodule Lux.Prisms.Twitter.AutoReply do
@moduledoc """
Auto-reply management for Twitter interactions.

Features:
- Template-based replies with variable substitution
- Keyword matching triggers
- Reply cooldowns per user
- Blocklist/allowlist filtering
"""

use GenServer

defstruct [:templates, :cooldowns, :blocklist, :reply_log, :cooldown_seconds]

def start_link(opts \\ []) do
name = opts[:name] || __MODULE__
GenServer.start_link(__MODULE__, opts, name: name)
end

def add_template(pid \\ __MODULE__, template) do
GenServer.call(pid, {:add_template, template})
end

def remove_template(pid \\ __MODULE__, template_id) do
GenServer.call(pid, {:remove_template, template_id})
end

def find_reply(pid \\ __MODULE__, tweet) do
GenServer.call(pid, {:find_reply, tweet})
end

def block_user(pid \\ __MODULE__, user_id) do
GenServer.call(pid, {:block_user, user_id})
end

def list_templates(pid \\ __MODULE__) do
GenServer.call(pid, :list_templates)
end

@impl true
def init(opts) do
{:ok, %__MODULE__{
templates: [],
cooldowns: %{},
blocklist: MapSet.new(),
reply_log: [],
cooldown_seconds: opts[:cooldown_seconds] || 300
}}
end

@impl true
def handle_call({:add_template, template}, _from, state) do
id = template[:id] || :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
entry = %{
id: id,
keywords: template[:keywords] || [],
reply_text: template[:reply_text],
variables: template[:variables] || [],
priority: template[:priority] || 0,
enabled: template[:enabled] != false
}
templates = [entry | state.templates] |> Enum.sort_by(& &1.priority, :desc)
{:reply, {:ok, entry}, %{state | templates: templates}}
end

@impl true
def handle_call({:remove_template, template_id}, _from, state) do
templates = Enum.reject(state.templates, &(&1.id == template_id))
{:reply, :ok, %{state | templates: templates}}
end

@impl true
def handle_call({:find_reply, tweet}, _from, state) do
user_id = tweet[:author_id]

cond do
MapSet.member?(state.blocklist, user_id) ->
{:reply, {:ok, nil}, state}

in_cooldown?(state.cooldowns, user_id, state.cooldown_seconds) ->
{:reply, {:ok, nil}, state}

true ->
text = String.downcase(tweet[:text] || "")
matching = state.templates
|> Enum.filter(& &1.enabled)
|> Enum.find(fn t ->
Enum.any?(t.keywords, &String.contains?(text, String.downcase(&1)))
end)

case matching do
nil ->
{:reply, {:ok, nil}, state}
template ->
reply = render_template(template.reply_text, tweet)
now = System.system_time(:second)
cooldowns = Map.put(state.cooldowns, user_id, now)
log = [%{tweet_id: tweet[:id], template_id: template.id, at: now} | state.reply_log]
{:reply, {:ok, %{reply_text: reply, template_id: template.id}}, %{state | cooldowns: cooldowns, reply_log: log}}
end
end
end

@impl true
def handle_call({:block_user, user_id}, _from, state) do
{:reply, :ok, %{state | blocklist: MapSet.put(state.blocklist, user_id)}}
end

@impl true
def handle_call(:list_templates, _from, state) do
{:reply, {:ok, state.templates}, state}
end

defp in_cooldown?(cooldowns, user_id, cooldown_seconds) do
case Map.get(cooldowns, user_id) do
nil -> false
last_time -> System.system_time(:second) - last_time < cooldown_seconds
end
end

defp render_template(text, tweet) do
text
|> String.replace("{{author}}", tweet[:author_name] || "")
|> String.replace("{{username}}", tweet[:author_username] || "")
end
end
Loading