-
Notifications
You must be signed in to change notification settings - Fork 2
feat(promoted): apply the moderation mute list to promoted entries #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| using System.Text.Json.Nodes; | ||
| using EcencyApi.Infrastructure; | ||
| using Xunit; | ||
|
|
||
| namespace EcencyApi.Tests; | ||
|
|
||
| /// <summary> | ||
| /// The moderation mute filter on promoted entries. The list is fetched from | ||
| /// chain, but the parts that can silently break a feed are pure: an empty or | ||
| /// unreadable list must leave the feed alone, and a match must remove exactly | ||
| /// the muted author's entries and nothing else. | ||
| /// </summary> | ||
| public class ModerationMutesTests | ||
| { | ||
| private static JsonArray Entries(params string?[] authors) | ||
| { | ||
| var arr = new JsonArray(); | ||
| foreach (var a in authors) | ||
| { | ||
| var o = new JsonObject { ["permlink"] = "p-" + (a ?? "none") }; | ||
| if (a != null) | ||
| { | ||
| o["author"] = a; | ||
| } | ||
| arr.Add(o); | ||
| } | ||
| return arr; | ||
| } | ||
|
|
||
| private static string?[] AuthorsOf(JsonArray arr) => | ||
| arr.Select(e => e is JsonObject o && o.TryGetPropertyValue("author", out var a) | ||
| ? a?.GetValue<string>() | ||
| : null).ToArray(); | ||
|
|
||
| [Fact] | ||
| public void AnEmptyMuteListLeavesTheFeedUntouched() | ||
| { | ||
| var entries = Entries("alice", "bob"); | ||
| var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(Array.Empty<string>())); | ||
| Assert.Equal(new[] { "alice", "bob" }, AuthorsOf(result)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MutedAuthorsAreDroppedAndTheRestKeptInOrder() | ||
| { | ||
| var entries = Entries("alice", "spammer", "bob", "spammer", "carol"); | ||
| var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); | ||
| Assert.Equal(new[] { "alice", "bob", "carol" }, AuthorsOf(result)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MatchingIsCaseInsensitive() | ||
| { | ||
| // Hive account names are lowercase, but nothing here guarantees the two | ||
| // sides were normalized by the same code path. | ||
| var entries = Entries("Spammer"); | ||
| var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); | ||
| Assert.Empty(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void AnEntryWithNoAuthorIsKept() | ||
| { | ||
| // An unreadable shape is not evidence of anything; dropping it would | ||
| // shrink the feed for a reason nobody could see. | ||
| var entries = Entries("alice", null); | ||
| var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); | ||
| Assert.Equal(2, result.Count); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void FilteringEveryEntryYieldsAnEmptyArrayNotNull() | ||
| { | ||
| var entries = Entries("spammer", "spammer"); | ||
| var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); | ||
| Assert.NotNull(result); | ||
| Assert.Empty(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ReadFollowingTakesTheFollowingNamesAndSkipsUnusableRows() | ||
| { | ||
| var rows = new JsonArray( | ||
| new JsonObject { ["follower"] = "ecency", ["following"] = "spammer", ["what"] = new JsonArray("ignore") }, | ||
| new JsonObject { ["follower"] = "ecency" }, | ||
| new JsonObject { ["follower"] = "ecency", ["following"] = "" }, | ||
| new JsonObject { ["follower"] = "ecency", ["following"] = "phisher", ["what"] = new JsonArray("ignore") }); | ||
|
|
||
| Assert.Equal(new[] { "spammer", "phisher" }, ModerationMutes.ReadFollowing(rows)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void TheModerationAccountIsEcency() | ||
| { | ||
| // Pinned: this account name is the whole control surface. A typo here | ||
| // would read as "nobody is muted" with no error anywhere. | ||
| Assert.Equal("ecency", ModerationMutes.Account); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| using System.Text.Json.Nodes; | ||
|
|
||
| namespace EcencyApi.Infrastructure; | ||
|
|
||
| /// <summary> | ||
| /// Ecency's on-chain moderation mute list. | ||
| /// | ||
| /// Muting an account from the moderation account is how spam and phishing are | ||
| /// kept out of the waves feeds; esync applies that list to every waves query it | ||
| /// serves. Promoted entries never go through esync, so without this they were | ||
| /// the one surface a muted account could still reach an audience through — and | ||
| /// the most prominent one, since a promoted card is a paid placement. | ||
| /// | ||
| /// Read straight from chain rather than from another service so this holds even | ||
| /// if the indexer is behind, and cached because the list changes only when a | ||
| /// moderator acts on it. | ||
| /// </summary> | ||
| public static class ModerationMutes | ||
| { | ||
| /// <summary>The account whose mutes are treated as platform-wide.</summary> | ||
| public const string Account = "ecency"; | ||
|
|
||
| private const string CacheKey = "moderation-muted-authors"; | ||
|
|
||
| /// <summary> | ||
| /// Survives a failed refresh, so an unreachable node degrades to the list we | ||
| /// last saw rather than to no filtering at all. Never expires on purpose. | ||
| /// </summary> | ||
| private const string LastGoodCacheKey = "moderation-muted-authors-last-good"; | ||
|
|
||
| private const double TtlSeconds = 300; | ||
|
|
||
| /// <summary>condenser_api.get_following caps a single response at 1000 rows.</summary> | ||
| private const int PageSize = 1000; | ||
|
|
||
| /// <summary> | ||
| /// Bounds the paging loop. 20 pages is 20k muted accounts, far past any real | ||
| /// list, so a node that stops advancing the cursor truncates rather than | ||
| /// looping forever. | ||
| /// </summary> | ||
| private const int MaxPages = 20; | ||
|
|
||
| /// <summary>Replaceable for tests (loopback stub nodes).</summary> | ||
| internal static HiveRpcClient Rpc = HiveClients.Default; | ||
|
|
||
| /// <summary> | ||
| /// The muted accounts, cached. Returns an empty set rather than throwing: | ||
| /// a moderation filter that cannot load must not take a feed down with it. | ||
| /// </summary> | ||
| public static async Task<HashSet<string>> Get() | ||
| { | ||
| var cached = MemCache.Get<string[]>(CacheKey); | ||
| if (cached != null) | ||
| { | ||
| return ToSet(cached); | ||
| } | ||
|
|
||
| try | ||
| { | ||
| var names = await Fetch(); | ||
| MemCache.Set(CacheKey, names, TtlSeconds); | ||
| MemCache.Set(LastGoodCacheKey, names); | ||
| return ToSet(names); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| Console.WriteLine($"warn: failed to fetch moderation mutes {e.Message}"); | ||
|
|
||
|
qodo-code-review[bot] marked this conversation as resolved.
Outdated
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
|
||
| // Re-arm the short TTL with the stale list so a node outage does not | ||
| // put an RPC call on every promoted-entries request for its duration. | ||
| var lastGood = MemCache.Get<string[]>(LastGoodCacheKey); | ||
| if (lastGood != null) | ||
| { | ||
| MemCache.Set(CacheKey, lastGood, TtlSeconds); | ||
| return ToSet(lastGood); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| return ToSet(Array.Empty<string>()); | ||
| } | ||
| } | ||
|
|
||
| private static async Task<string[]> Fetch() | ||
| { | ||
| var names = new List<string>(); | ||
| var start = ""; | ||
|
|
||
| for (var page = 0; page < MaxPages; page++) | ||
| { | ||
| var result = await Rpc.Call("condenser_api", "get_following", | ||
| new JsonArray(Account, start, "ignore", PageSize)); | ||
|
|
||
| if (result is not JsonArray rows || rows.Count == 0) | ||
| { | ||
| break; | ||
| } | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
|
|
||
| var pageNames = ReadFollowing(rows); | ||
|
|
||
| // `start` is exclusive on Hive, so a page should not repeat the | ||
| // cursor. Drop it anyway: against a node treating it as inclusive | ||
| // this would re-append the same account until the page cap. | ||
| if (pageNames.Count > 0 && pageNames[0] == start) | ||
| { | ||
| pageNames.RemoveAt(0); | ||
| } | ||
|
|
||
| if (pageNames.Count == 0) | ||
| { | ||
| break; | ||
| } | ||
|
|
||
| names.AddRange(pageNames); | ||
|
|
||
| if (rows.Count < PageSize) | ||
| { | ||
| break; | ||
| } | ||
|
|
||
| start = pageNames[^1]; | ||
| } | ||
|
|
||
| return names.ToArray(); | ||
| } | ||
|
|
||
| internal static List<string> ReadFollowing(JsonArray rows) | ||
| { | ||
| var names = new List<string>(); | ||
| foreach (var row in rows) | ||
| { | ||
| var name = row is JsonObject o && o.TryGetPropertyValue("following", out var f) | ||
| ? f?.GetValue<string>() | ||
| : null; | ||
| if (!string.IsNullOrEmpty(name)) | ||
| { | ||
| names.Add(name); | ||
| } | ||
| } | ||
| return names; | ||
| } | ||
|
|
||
| internal static HashSet<string> ToSet(IEnumerable<string> names) => | ||
| new(names, StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| /// <summary> | ||
| /// Drop entries authored by a muted account. Returns a new array; entries | ||
| /// with no readable author are kept, since an unreadable shape is not | ||
| /// evidence of anything and dropping it would silently shrink the feed. | ||
| /// </summary> | ||
| public static JsonArray FilterMutedAuthors(JsonArray entries, ISet<string> muted) | ||
| { | ||
| if (muted.Count == 0) | ||
| { | ||
| return entries; | ||
| } | ||
|
|
||
| var kept = new JsonArray(); | ||
| foreach (var entry in entries.ToArray()) | ||
| { | ||
| var author = entry is JsonObject o && o.TryGetPropertyValue("author", out var a) | ||
| ? a?.GetValue<string>() | ||
| : null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
cat -n dotnet/EcencyApi/Infrastructure/ModerationMutes.cs | sed -n '1,220p'
printf '%s\n' '--- lenient-string helper definitions and usages ---'
rg -n -C 3 'TryGetStringLenient|class JsVal|static class JsVal' dotnet/EcencyApi dotnet --glob '*.cs' | sed -n '1,260p'Repository: ecency/vision-api Length of output: 28257 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- JsVal implementation ---'
cat -n dotnet/EcencyApi/Infrastructure/JsVal.cs | sed -n '1,105p'
printf '%s\n' '--- ModerationMutes callers and tests ---'
rg -n -C 5 'ReadFollowing|FilterMutedAuthors|ModerationMutes|Promoted' dotnet/EcencyApi dotnet/EcencyApi.Tests --glob '*.cs' | sed -n '1,320p'
printf '%s\n' '--- project/runtime availability ---'
if command -v dotnet >/dev/null 2>&1; then
dotnet --version
else
printf '%s\n' 'dotnet unavailable'
fiRepository: ecency/vision-api Length of output: 27801 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
target = Path("dotnet/EcencyApi/Infrastructure/ModerationMutes.cs").read_text()
handler = Path("dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs").read_text()
helper = Path("dotnet/EcencyApi/Infrastructure/JsVal.cs").read_text()
checks = {
"following uses GetValue<string>": 'f?.GetValue<string>()' in target,
"author uses GetValue<string>": 'a?.GetValue<string>()' in target,
"following is used by Fetch": 'var pageNames = ReadFollowing(rows);' in target,
"author filtering is used by promoted endpoint":
'posts = ModerationMutes.FilterMutedAuthors(posts, await ModerationMutes.Get());' in handler,
"Get catches Fetch failures": 'catch (Exception e)' in target and 'var names = await Fetch();' in target,
"Get falls back to last-good list": 'var lastGood = MemCache.Get<string[]>(LastGoodCacheKey);' in target,
"unreadable authors are documented as retained":
'entries with no readable author are kept' in target,
"lenient helper exists": 'public static bool TryGetStringLenient(JsonValue v, out string value)' in helper,
"lenient helper returns false for non-strings": 'value = null!;\n return false;' in helper,
}
for name, result in checks.items():
print(f"{name}: {'PASS' if result else 'FAIL'}")
if not all(checks.values()):
raise SystemExit(1)
PYRepository: ecency/vision-api Length of output: 516 Use
Replace both reads with 📍 Affects 1 file
🤖 Prompt for AI AgentsSource: Coding guidelines
qodo-code-review[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (author != null && muted.Contains(author)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| // A node can only live in one parent, and these come from a cache | ||
| // clone we own, so detach before re-parenting into the result. | ||
| entry?.Parent?.AsArray().Remove(entry); | ||
| kept.Add(entry); | ||
| } | ||
|
|
||
| return kept; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.