tests for is_nsfw_live_announcement

This commit is contained in:
CJ_Clippy 2025-03-15 17:04:57 -08:00
parent 2ffa655163
commit 719fe79f73
11 changed files with 424 additions and 89 deletions

@ -10,6 +10,7 @@ defmodule Bright.ObanWorkers.ProcessPosts do
alias Bright.Socials.XPost
alias Bright.Streams.Stream
alias Bright.Platforms.Platform
alias Bright.Platforms
import Ecto.Query
require Logger
@ -23,10 +24,10 @@ defmodule Bright.ObanWorkers.ProcessPosts do
def perform(%Oban.Job{args: %{}}) do
Logger.info(">>>> Process Posts is performing.")
known_platforms = Repo.all(Platform)
known_platforms = Platform |> Repo.all() |> Repo.preload(:platform_aliases)
{num, nil} =
get_unprocessed_posts()
XPost.get_unprocessed_posts()
|> then(fn posts ->
if posts == [] do
Logger.info("No unprocessed posts found")
@ -42,19 +43,17 @@ defmodule Bright.ObanWorkers.ProcessPosts do
def process_post(post, known_platforms) do
with platforms <- XPost.get_platforms_mentioned(post, known_platforms),
true <- is_nsfw_live_annoucement?(post, platforms, known_platforms),
true <- XPost.is_nsfw_live_announcement?(post, platforms, known_platforms),
{:ok, _stream} <- create_stream(post, platforms) do
:ok
else
_ -> :ok
end
end
idk ->
Logger.debug(
"process_post did not find a nsfw live announcement. post=#{inspect(post)} known_platforms=#{inspect(known_platforms)}"
)
def get_unprocessed_posts() do
XPost
|> where([p], is_nil(p.processed_at))
|> preload(:vtuber)
|> Repo.all()
:ok
end
end
@doc """
@ -99,43 +98,4 @@ defmodule Bright.ObanWorkers.ProcessPosts do
# No posts to update
defp mark_posts_as_processed(_), do: :ok
@doc """
Is the post a valid NSFW livestream announcement?
Eligibility requirements.
To be considered a NSFW live announcement, a post must satisfy all the following conditions.
* The post is authored by the lewdtuber
* The post mentions a NSFW platform
* The post does not contain any URLs to SFW streaming platforms.
"""
def is_nsfw_live_annoucement?(%XPost{vtuber: vtuber} = post, platforms, known_platforms) do
Logger.debug("Checking if post is NSFW live announcement: #{inspect(post)}")
nsfw_platforms = Enum.filter(known_platforms, & &1.nsfw?)
sfw_platforms = Enum.reject(known_platforms, & &1.nsfw?)
conditions = [
{:authored_by_vtuber?, not is_nil(vtuber)},
{:contains_nsfw_link?,
Enum.any?(platforms, fn plat -> Enum.any?(nsfw_platforms, &match_platform?(plat, &1)) end)},
{:no_sfw_link?,
not Enum.any?(platforms, fn plat ->
Enum.any?(sfw_platforms, &match_platform?(plat, &1))
end)}
]
Enum.reduce_while(conditions, true, fn {label, condition}, _acc ->
if condition do
{:cont, true}
else
Logger.debug("NSFW announcement check failed at: #{label}")
{:halt, false}
end
end)
end
defp match_platform?(plat, platform), do: String.contains?(plat, &URI.parse(&1.url).hostname)
end

@ -38,8 +38,8 @@ defmodule Bright.Platforms do
"""
def get_platform!(id) do
Platform
|> Repo.get!(Platform, id)
|> Repo.preload([:platform_aliases])
|> Repo.get!(id)
|> Repo.preload(:platform_aliases)
end
@doc """
@ -125,4 +125,15 @@ defmodule Bright.Platforms do
|> PlatformAlias.changeset(attrs)
|> Repo.insert()
end
def match_platform?(a, b) do
URI.parse(a.url).host === URI.parse(b.url).host
end
@doc """
Do any of the A platforms match any of the B platforms?
"""
def contains_platform?(a, b) do
Enum.any?(a, fn plat -> Enum.any?(b, &match_platform?(plat, &1)) end)
end
end

@ -18,5 +18,6 @@ defmodule Bright.Platforms.Platform do
platform
|> cast(attrs, [:name, :url, :slug])
|> validate_required([:name, :url, :slug])
|> unique_constraint(:name)
end
end

@ -1,7 +1,26 @@
defmodule Bright.Socials do
alias Bright.Socials.XPost
alias Bright.Repo
@moduledoc """
Socials context, for functions for interacting with social media platforms
"""
@doc """
Creates a x_post.
## Examples
iex> x_post(%{field: value})
{:ok, %XPost{}}
iex> x_post(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_x_post(attrs \\ %{}) do
%XPost{}
|> XPost.changeset(attrs)
|> Repo.insert()
end
end

@ -5,6 +5,7 @@ defmodule Bright.Socials.XPost do
alias Bright.Vtubers.Vtuber
alias Bright.Socials.{XPost, RSSParser}
alias Bright.Platforms.Platform
alias Bright.Platforms
alias Quinn
require Logger
@ -66,6 +67,17 @@ defmodule Bright.Socials.XPost do
end
end
def authored_by_vtuber?(x_post, vtuber) do
x_post.vtuber_id === vtuber.id
end
def get_unprocessed_posts() do
XPost
|> where([p], is_nil(p.processed_at))
|> Repo.all()
|> Repo.preload(:vtuber)
end
def extract_hostname(url) do
uri = URI.parse(url)
uri.host || ""
@ -113,6 +125,45 @@ defmodule Bright.Socials.XPost do
def get_platforms_mentioned(raw_text, platforms) do
Enum.filter(platforms, &includes_platform?(raw_text, &1))
end
@doc """
Is the post a valid NSFW livestream announcement?
Eligibility requirements.
To be considered a NSFW live announcement, a post must satisfy all the following conditions.
* The post is authored by the lewdtuber
* The post does not contain, "VOD/i"
* The post mentions a NSFW platform
* The post does not mention any SFW streaming platform.
"""
def is_nsfw_live_announcement?(
%XPost{vtuber: vtuber} = post,
mentioned_platforms,
known_platforms
) do
Logger.debug("Checking if post is NSFW live announcement: #{inspect(post)}")
nsfw_platforms = Enum.filter(known_platforms, & &1.nsfw)
sfw_platforms = Enum.reject(known_platforms, & &1.nsfw)
conditions = [
# {:authored_by_vtuber?, XPost.authored_by_vtuber?(post, vtuber)}, # This one might not make sense. I think we only get posts from the vtuber's feed
{:not_vod?, not String.contains?(String.downcase(post.raw), "vod")},
{:contains_nsfw_link?, Platforms.contains_platform?(mentioned_platforms, nsfw_platforms)},
{:no_sfw_link?, not Platforms.contains_platform?(mentioned_platforms, sfw_platforms)}
]
Enum.reduce_while(conditions, true, fn {label, condition}, _acc ->
if condition do
{:cont, true}
else
Logger.debug("NSFW announcement check failed at: #{label}")
{:halt, false}
end
end)
end
end
defimpl Phoenix.HTML.Safe, for: Bright.Socials.XPost do

@ -7,47 +7,82 @@ defmodule Bright.ProcessPostsTest do
alias Bright.Streams
alias Bright.Streams.Stream
alias Bright.Platforms.Platform
alias Bright.Platforms
alias Bright.VtubersFixtures
alias Bright.Socials.XPost
alias Bright.Socials
setup do
vtuber = Bright.VtubersFixtures.vtuber_fixture()
%Platform{
Platforms.create_platform(%{
name: "Fansly",
slug: "fansly",
url: "https://fansly.com",
nsfw: true
}
|> Repo.insert!()
})
%Platform{
Platforms.create_platform(%{
name: "Twitch",
slug: "twitch",
url: "https://twitch.tv",
nsfw: false
}
|> Repo.insert!()
})
%Platform{
Platforms.create_platform(%{
name: "OnlyFans",
slug: "onlyfans",
url: "https://onlyfans.com",
nsfw: true
}
|> Repo.insert!()
})
posts =
for i <- 1..3 do
%XPost{
raw: "Raw content #{i}",
url: "https://example.com/post#{i}",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
}
|> Repo.insert!()
end
Platforms.create_platform(%{
name: "Chaturbate",
slug: "chaturbate",
url: "https://chaturbate.com",
nsfw: true
})
posts = [
# these posts are valid nsfw livestream announcements
Socials.create_x_post(%{
raw: "I'm going live! fansly.com/fakename <3",
url: "https://x.com/fakename/status/283498235",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
}),
Socials.create_x_post(%{
raw: "gm! tiem for sex breakfast https://onlyfans.com/fakename",
url: "https://x.com/fakename/status/283498234",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
}),
Socials.create_x_post(%{
raw: "ero strim rn http://chaturbate.com/fakename",
url: "https://x.com/fakename/status/283498232",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
}),
# these posts are NOT valid livestream invitations
Socials.create_x_post(%{
raw: "Let's play a game http://twitch.tv/fakename",
url: "https://x.com/fakename/status/283498343",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
}),
Socials.create_x_post(%{
raw:
"Be sure to follow me on my socials http://chaturbate.com/fakename http://twitch.tv/fakename http://onlyfans.com/fakename http://linktree.com/fakename",
url: "https://x.com/fakename/status/283498349",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
})
]
{:ok, posts: posts, vtuber: vtuber}
end

@ -2,31 +2,29 @@ defmodule Bright.PlatformsTest do
use Bright.DataCase
alias Bright.Platforms
alias Bright.Platforms.Platform
describe "platforms" do
alias Bright.Platforms.Platform
import Bright.PlatformsFixtures
@invalid_attrs %{name: 7, url: 7, icon: 7}
@invalid_attrs %{name: 7, url: 7}
test "list_platforms/0 returns all platforms" do
platform = platform_fixture()
assert Platforms.list_platforms() == [platform]
assert Platforms.list_platforms() |> length === 1
end
test "get_platform!/1 returns the platform with given id" do
platform = platform_fixture()
assert Platforms.get_platform!(platform.id) == platform
platform = platform_fixture(%{name: "Chaturbate"})
assert Platforms.get_platform!(platform.id).name == platform.name
end
test "create_platform/1 with valid data creates a platform" do
valid_attrs = %{name: "some name", url: "some url", icon: "<svg></svg>"}
valid_attrs = %{name: "some name", url: "some url", slug: "some_slug"}
assert {:ok, %Platform{} = platform} = Platforms.create_platform(valid_attrs)
assert platform.name == "some name"
assert platform.url == "some url"
assert platform.icon == "<svg></svg>"
end
test "create_platform/1 with invalid data returns error changeset" do
@ -38,20 +36,17 @@ defmodule Bright.PlatformsTest do
update_attrs = %{
name: "some updated name",
url: "https://example.com",
icon: "<svg>blah</svg>"
url: "https://example.com"
}
assert {:ok, %Platform{} = platform} = Platforms.update_platform(platform, update_attrs)
assert platform.name == "some updated name"
assert platform.url == "https://example.com"
assert platform.icon == "<svg>blah</svg>"
end
test "update_platform/2 with invalid data returns error changeset" do
platform = platform_fixture()
assert {:error, %Ecto.Changeset{}} = Platforms.update_platform(platform, @invalid_attrs)
assert platform == Platforms.get_platform!(platform.id)
assert {:error, %Ecto.Changeset{}} = platform |> Platforms.update_platform(@invalid_attrs)
end
test "delete_platform/1 deletes the platform" do
@ -65,4 +60,39 @@ defmodule Bright.PlatformsTest do
assert %Ecto.Changeset{} = Platforms.change_platform(platform)
end
end
describe "match_platform?/2" do
test "compares a platform with another and returns true if the two are the same platform" do
platformA = %Platform{name: "Twitch", url: "https://twitch.tv"}
platformB = %Platform{name: "Chaturbate", url: "https://chaturbate.com"}
platformC = %Platform{name: "Twitch", url: "https://twitch.tv"}
assert Platforms.match_platform?(platformA, platformC)
assert not Platforms.match_platform?(platformA, platformB)
end
end
describe "contains_platform?/2" do
@tag :unit
test "accepts a list of platforms and returns true if one of them match any of a list of given platform" do
platformA = %Platform{name: "Twitch", url: "https://twitch.tv"}
platformB = %Platform{name: "Chaturbate", url: "https://chaturbate.com"}
platformC = %Platform{name: "Twitch", url: "https://twitch.tv"}
assert Platforms.contains_platform?([platformA], [platformB, platformC])
assert not Platforms.contains_platform?([platformA], [platformB])
end
@tag :unit
test "returns true if configured to return true for SFW platforms" do
platformA = %Platform{name: "Twitch", url: "https://twitch.tv"}
platformB = %Platform{name: "YouTube", url: "https://youtube.com"}
assert Platforms.contains_platform?([platformA], [platformB, platformA])
end
@tag :unit
test "returns false if matching against an empty list" do
platformA = %Platform{name: "Twitch", url: "https://twitch.tv"}
assert not Platforms.contains_platform?([platformA], [])
assert not Platforms.contains_platform?([], [platformA])
end
end
end

@ -2,8 +2,10 @@ defmodule Bright.XPostTest do
use Bright.DataCase
alias Bright.Socials.XPost
alias Bright.Socials
alias Bright.Vtubers
alias Bright.Vtubers.Vtuber
alias Bright.XPostsFixtures
alias Bright.{SocialsFixtures, VtubersFixtures, XPostsFixtures, PlatformsFixtures}
alias Bright.Platforms.{Platform, PlatformAlias}
alias Bright.Platforms
alias Bright.VultrAI
@ -11,6 +13,31 @@ defmodule Bright.XPostTest do
@sample_feed "https://rss.app/feeds/FhPetvUY036xiFau.xml"
describe "authored_by_vtuber?/2" do
@tag :unit
test "returns true when the given post is authored by the given vtuber" do
vtuber = VtubersFixtures.vtuber_fixture(%{name: "ProjektMelody", slug: "projektmelody"})
x_post = SocialsFixtures.x_post_fixture(%{vtuber_id: vtuber.id})
assert XPost.authored_by_vtuber?(x_post, vtuber)
end
@tag :unit
test "returns false when the given post is NOT authored by the given vtuber" do
vtuber = VtubersFixtures.vtuber_fixture(%{name: "ProjektMelody", slug: "projektmelody"})
vtuberB = VtubersFixtures.vtuber_fixture(%{name: "Vex", slug: "vex"})
{:ok, x_post} =
Socials.create_x_post(%{
raw: "test",
url: "https://x.com/projektmelody/status/1234",
date: DateTime.utc_now(:second),
vtuber_id: vtuber.id
})
assert not XPost.authored_by_vtuber?(x_post, vtuberB)
end
end
describe "get_new_posts" do
@tag :integration
test "get_new_posts/1 with URL" do
@ -35,6 +62,36 @@ defmodule Bright.XPostTest do
end
end
describe "get_unprocessed_posts/0" do
setup do
vtuber =
%Vtuber{
display_name: "Some Vtuber",
slug: "some-vtuber"
}
|> Repo.insert!()
posts =
for i <- 1..3 do
%XPost{
raw: "Raw content #{i}",
url: "https://example.com/post#{i}",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
}
|> Repo.insert!()
end
{:ok, posts: posts}
end
@tag :unit
test "gets posts with nil processed_at" do
assert length(XPost.get_unprocessed_posts()) == 3
end
end
describe "includes_alias?/2" do
setup do
ytmnd =
@ -289,5 +346,100 @@ defmodule Bright.XPostTest do
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
@tag :unit
test "post with a platform alias" do
known_platforms = Platforms.list_platforms()
expected_platform_names = ["Fansly"]
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_live_4() |> Map.get(:raw),
known_platforms
)
|> Enum.map(& &1.name)
assert actual_platform_names == expected_platform_names
end
end
describe "is_nsfw_live_announcement?/3" do
setup do
vtuber = VtubersFixtures.vtuber_fixture()
{:ok, x_post} =
Socials.create_x_post(%{
raw: "I'm going live https://twitch.tv/bigchungus",
url: "https://x.com/bigchungus/status/1234",
date: DateTime.utc_now(:second),
vtuber_id: vtuber.id
})
x_post = Repo.preload(x_post, :vtuber)
known_platforms = PlatformsFixtures.known_platforms_fixture()
mentioned_platforms = [
PlatformsFixtures.onlyfans_fixture(),
PlatformsFixtures.chaturbate_fixture(),
PlatformsFixtures.fansly_fixture()
]
{:ok,
vtuber: vtuber,
x_post: x_post,
known_platforms: known_platforms,
mentioned_platforms: mentioned_platforms}
end
@tag :integration
test "should return false when receiving a XPost linking to a SFW platform", %{
vtuber: vtuber,
x_post: x_post,
known_platforms: known_platforms
} do
mentioned_platforms = [
PlatformsFixtures.twitch_fixture(),
PlatformsFixtures.fansly_fixture(),
PlatformsFixtures.onlyfans_fixture(),
PlatformsFixtures.chaturbate_fixture()
]
assert not XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms)
end
test "should return true when receiving an XPost with only Chaturbate mentioned", %{
vtuber: vtuber,
x_post: x_post,
known_platforms: known_platforms,
mentioned_platforms: mentioned_platforms
} do
mentioned_platforms = [
PlatformsFixtures.chaturbate_fixture()
]
assert XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms)
end
test "should return true when receiving an XPost with only NSFW platforms mentioned", %{
vtuber: vtuber,
x_post: x_post,
known_platforms: known_platforms,
mentioned_platforms: mentioned_platforms
} do
assert XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms)
end
test "should return false when receiving an XPost with vod/i", %{
vtuber: vtuber,
known_platforms: known_platforms
} do
x_post = %XPost{
raw:
"IRL JOI handcam stream! Listen to my instructions and stroke your cock for me until you cum 🍆💦\n\nThe rest of the VOD is available here for Tier 1 subscribers or for $10! 💛\n▶️ https://fansly.com/post/755934614"
}
mentioned_platforms = [PlatformsFixtures.fansly_fixture()]
assert not XPost.is_nsfw_live_announcement?(x_post, mentioned_platforms, known_platforms)
end
end
end

@ -4,16 +4,16 @@ defmodule Bright.PlatformsFixtures do
entities via the `Bright.Platforms` context.
"""
def platform_fixture(attrs \\ %{})
alias Bright.Platforms.Platform
@doc """
Generate a platform.
"""
def platform_fixture(attrs) do
def platform_fixture(attrs \\ %{}) do
{:ok, platform} =
attrs
|> Enum.into(%{
icon: "some icon",
slug: "some_slug",
name: "some name",
url: "some url"
})
@ -21,4 +21,49 @@ defmodule Bright.PlatformsFixtures do
platform
end
def known_platforms_fixture() do
[
twitch_fixture(),
fansly_fixture(),
chaturbate_fixture(),
onlyfans_fixture(),
linktree_fixture(),
discord_fixture(),
carrd_fixture(),
throne_fixture()
]
end
def twitch_fixture() do
%Platform{name: "Twitch", slug: "twitch", url: "https://twitch.tv", nsfw: false}
end
def fansly_fixture() do
%Platform{name: "Fansly", slug: "fansly", url: "https://fansly.com", nsfw: true}
end
def chaturbate_fixture() do
%Platform{name: "Chaturbate", slug: "chaturbate", url: "https://chaturbate.com", nsfw: true}
end
def onlyfans_fixture() do
%Platform{name: "OnlyFans", slug: "onlyfans", url: "https://onlyfans.com", nsfw: true}
end
def linktree_fixture() do
%Platform{name: "Linktree", slug: "linktree", url: "https://linktr.ee", nsfw: false}
end
def discord_fixture() do
%Platform{name: "Discord", slug: "discord", url: "https://discord.com", nsfw: false}
end
def carrd_fixture() do
%Platform{name: "Carrd", slug: "carrd", url: "https://carrd.co", nsfw: false}
end
def throne_fixture() do
%Platform{name: "Throne", slug: "throne", url: "https://throne.com", nsfw: false}
end
end

@ -0,0 +1,23 @@
defmodule Bright.SocialsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Bright.Socials` context.
"""
@doc """
Generate a platform.
"""
def x_post_fixture(attrs \\ %{}) do
{:ok, x_post} =
attrs
|> Enum.into(%{
raw: "some raw text",
url: "https://x.com/fakeuser/status/9876",
processed_at: nil,
date: DateTime.utc_now(:second)
})
|> Bright.Socials.create_x_post()
x_post
end
end

@ -82,6 +82,14 @@ defmodule Bright.XPostsFixtures do
}
end
def fixture_live_4() do
%XPost{
raw: "http://melody.buzz",
date: ~U[2025-05-05T05:05:05.000Z],
url: "https://x.com/ProjektMelody/status/5555"
}
end
@doc """
Generates a basic x_post fixture.
"""