Skip to content

Commit f737562

Browse files
committed
better reaction handling
1 parent fcae8e7 commit f737562

10 files changed

Lines changed: 123 additions & 18 deletions

File tree

‎runtime/src/api/client.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ interface CounterState {
66
id: string | null;
77
up: number;
88
down: number;
9+
upvotes: number;
10+
reactions: Record<string, number>;
911
age: number;
1012
stale: boolean;
1113
}
@@ -363,6 +365,8 @@ function emptyState(): ReactionState {
363365
id: null,
364366
up: 0,
365367
down: 0,
368+
upvotes: 0,
369+
reactions: {},
366370
age: 0,
367371
stale: false,
368372
viewer: "none",
@@ -384,6 +388,8 @@ function isCounterState(value: unknown): value is CounterState {
384388
(state.id === null || typeof state.id === "string") &&
385389
Number.isSafeInteger(state.up) && (state.up ?? -1) >= 0 &&
386390
Number.isSafeInteger(state.down) && (state.down ?? -1) >= 0 &&
391+
Number.isSafeInteger(state.upvotes) && (state.upvotes ?? -1) >= 0 &&
392+
!!state.reactions && typeof state.reactions === "object" &&
387393
Number.isSafeInteger(state.age) && (state.age ?? -1) >= 0 &&
388394
typeof state.stale === "boolean"
389395
);

‎src/feedback/api/routes.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,31 @@ async def reactions(request: Request) -> Response:
4848
await container.refresh_stale(site, cached)
4949
cached = container.databases[site.id].reactions(keys)
5050
now = int(container.clock())
51-
items: dict[str, dict[str, str | int | bool | None]] = {}
51+
items: dict[str, dict[str, object]] = {}
5252
for key in keys:
5353
item = cached.get(key)
5454
if item is None:
55-
items[key] = {"id": None, "up": 0, "down": 0, "age": 0, "stale": False}
55+
items[key] = {
56+
"id": None, "up": 0, "down": 0, "upvotes": 0,
57+
"reactions": {name: 0 for name in site.reaction_counters},
58+
"age": 0, "stale": False,
59+
}
5660
continue
5761
age = max(0, now - item.fetched_at)
62+
reactions = {
63+
name: item.reactions.get(name, 0)
64+
for name in site.reaction_counters
65+
}
66+
thumbsup = item.up
67+
up = item.up if site.upvote_source == "thumbsup" else item.upvotes
68+
if site.upvote_source == "both":
69+
up = item.up + item.upvotes
5870
items[key] = {
5971
"id": item.node_id,
60-
"up": item.up,
61-
"down": item.down,
72+
"up": up,
73+
"down": item.down if site.downvotes else 0,
74+
"upvotes": item.upvotes,
75+
"reactions": {**reactions, "THUMBS_UP": thumbsup},
6276
"age": age,
6377
"stale": age >= site.cache_fresh_seconds,
6478
}

‎src/feedback/config.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class ConfigError(ValueError):
1616

1717
_SITE_ID = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
1818
_MAPPINGS = frozenset({"key", "title", "url", "pathname", "custom", "number"})
19+
_UPVOTE_SOURCES = frozenset({"thumbsup", "native", "both"})
20+
_REACTIONS = frozenset({"LAUGH", "HOORAY", "CONFUSED", "HEART", "ROCKET", "EYES"})
1921
_SERVICE_KEYS = frozenset(
2022
{
2123
"public_origin",
@@ -41,6 +43,9 @@ class ConfigError(ValueError):
4143
"refresh_cooldown_seconds",
4244
"refresh_sweep_seconds",
4345
"max_batch_size",
46+
"downvotes",
47+
"upvote_source",
48+
"reaction_counters",
4449
}
4550
)
4651

@@ -71,6 +76,9 @@ class SiteConfig:
7176
refresh_cooldown_seconds: int = 5
7277
refresh_sweep_seconds: int = 86_400
7378
max_batch_size: int = 100
79+
downvotes: bool = True
80+
upvote_source: str = "thumbsup"
81+
reaction_counters: tuple[str, ...] = tuple(sorted(_REACTIONS))
7482

7583

7684
@dataclass(frozen=True, slots=True)
@@ -138,6 +146,18 @@ def load_config(path: Path | str, *, data_directory: Path | None = None) -> Conf
138146
repository = _string(value, "repository")
139147
if repository.count("/") != 1 or any(not part for part in repository.split("/")):
140148
raise ConfigError(f"sites.{site_id}.repository must be owner/name")
149+
downvotes = _bool(value, "downvotes", True)
150+
upvote_source = _string(value, "upvote_source") if "upvote_source" in value else "thumbsup"
151+
if upvote_source not in _UPVOTE_SOURCES:
152+
raise ConfigError(f"sites.{site_id}.upvote_source is unsupported")
153+
reaction_counters = (
154+
tuple(item.upper() for item in _string_list(value, "reaction_counters"))
155+
if "reaction_counters" in value else tuple(sorted(_REACTIONS))
156+
)
157+
if not reaction_counters or any(item not in _REACTIONS for item in reaction_counters):
158+
raise ConfigError(f"sites.{site_id}.reaction_counters contains an unsupported reaction")
159+
if len(set(reaction_counters)) != len(reaction_counters):
160+
raise ConfigError(f"sites.{site_id}.reaction_counters contains duplicates")
141161

142162
sites[site_id] = SiteConfig(
143163
id=site_id,
@@ -154,6 +174,9 @@ def load_config(path: Path | str, *, data_directory: Path | None = None) -> Conf
154174
value, "refresh_sweep_seconds", 3600, 604_800, 86_400
155175
),
156176
max_batch_size=_bounded_int(value, "max_batch_size", 1, 100, 100),
177+
downvotes=downvotes,
178+
upvote_source=upvote_source,
179+
reaction_counters=reaction_counters,
157180
)
158181

159182
return Config(service, MappingProxyType(sites))
@@ -186,6 +209,13 @@ def _string_list(value: Mapping[str, Any], key: str) -> list[str]:
186209
return result
187210

188211

212+
def _bool(value: Mapping[str, Any], key: str, default: bool) -> bool:
213+
result = value.get(key, default)
214+
if not isinstance(result, bool):
215+
raise ConfigError(f"{key} must be a boolean")
216+
return result
217+
218+
189219
def _bounded_int(
190220
value: Mapping[str, Any],
191221
key: str,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
BEGIN IMMEDIATE;
2+
3+
ALTER TABLE discussions ADD COLUMN upvotes INTEGER NOT NULL DEFAULT 0 CHECK (upvotes >= 0);
4+
5+
PRAGMA user_version = 2;
6+
7+
COMMIT;

‎src/feedback/database/migrations/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from importlib.resources import files
22

3-
NAMES = ("0001_initial.sql",)
3+
NAMES = ("0001_initial.sql", "0002_reaction_intents.sql")
44

55

66
def scripts() -> tuple[str, ...]:
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
SELECT resource_id, github_node_id, up_count, down_count, fetched_at
2-
FROM discussions
3-
WHERE resource_id IN (SELECT value FROM json_each(?))
1+
SELECT d.resource_id, d.github_node_id, d.up_count, d.down_count,
2+
d.upvotes, d.fetched_at,
3+
COALESCE(json_group_object(rc.reaction, rc.count) FILTER (WHERE rc.reaction IS NOT NULL), '{}')
4+
FROM discussions d
5+
LEFT JOIN reaction_counts rc ON rc.resource_id = d.resource_id
6+
WHERE d.resource_id IN (SELECT value FROM json_each(?))
7+
GROUP BY d.resource_id
Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
SELECT resource_id, github_node_id, up_count, down_count, fetched_at
2-
FROM discussions
3-
WHERE resource_id > ? AND fetched_at <= ?
4-
ORDER BY resource_id
1+
SELECT d.resource_id, d.github_node_id, d.up_count, d.down_count,
2+
d.upvotes, d.fetched_at,
3+
COALESCE(json_group_object(rc.reaction, rc.count) FILTER (WHERE rc.reaction IS NOT NULL), '{}')
4+
FROM discussions d
5+
LEFT JOIN reaction_counts rc ON rc.resource_id = d.resource_id
6+
WHERE d.resource_id > ? AND d.fetched_at <= ?
7+
GROUP BY d.resource_id
8+
ORDER BY d.resource_id
59
LIMIT ?

‎src/feedback/database/queries/update_reactions.sql‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ UPDATE discussions
22
SET
33
up_count = ?,
44
down_count = ?,
5+
upvotes = ?,
56
locked = ?,
67
github_updated_at = ?,
78
fetched_at = ?

‎src/feedback/database/sqlite.py‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ class ReactionCounts:
2828
node_id: str
2929
up: int
3030
down: int
31+
upvotes: int
3132
fetched_at: int
33+
reactions: dict[str, int]
3234

3335

3436
@dataclass(frozen=True, slots=True)
@@ -71,7 +73,10 @@ def reactions(self, resource_ids: Iterable[str]) -> dict[str, ReactionCounts]:
7173
with self.connect() as connection:
7274
rows = connection.execute(REACTIONS, (json.dumps(keys),)).fetchall()
7375
return {
74-
row[0]: ReactionCounts(node_id=row[1], up=row[2], down=row[3], fetched_at=row[4])
76+
row[0]: ReactionCounts(
77+
node_id=row[1], up=row[2], down=row[3], upvotes=row[4], fetched_at=row[5],
78+
reactions=json.loads(row[6]),
79+
)
7580
for row in rows
7681
}
7782

@@ -86,7 +91,10 @@ def tracked_reactions(
8691
with self.connect() as connection:
8792
rows = connection.execute(TRACKED_REACTIONS, (after, fetched_before, limit)).fetchall()
8893
return [
89-
(row[0], ReactionCounts(node_id=row[1], up=row[2], down=row[3], fetched_at=row[4]))
94+
(row[0], ReactionCounts(
95+
node_id=row[1], up=row[2], down=row[3], upvotes=row[4], fetched_at=row[5],
96+
reactions=json.loads(row[6]),
97+
))
9098
for row in rows
9199
]
92100

@@ -101,6 +109,8 @@ def put_discussion(
101109
url: str,
102110
up: int = 0,
103111
down: int = 0,
112+
upvotes: int = 0,
113+
reactions: dict[str, int] | None = None,
104114
fetched_at: int | None = None,
105115
) -> None:
106116
fetched = int(time.time()) if fetched_at is None else fetched_at
@@ -119,22 +129,42 @@ def put_discussion(
119129
fetched,
120130
),
121131
)
132+
connection.execute(
133+
"UPDATE discussions SET upvotes = ? WHERE resource_id = ?",
134+
(upvotes, resource_id),
135+
)
136+
if reactions:
137+
connection.executemany(
138+
"INSERT OR REPLACE INTO reaction_counts (resource_id, reaction, count) VALUES (?, ?, ?)",
139+
[(resource_id, name, count) for name, count in reactions.items()],
140+
)
122141

123142
def update_reactions(
124143
self,
125144
*,
126145
node_id: str,
127146
up: int,
128147
down: int,
148+
upvotes: int,
149+
reactions: dict[str, int],
129150
locked: bool,
130151
github_updated_at: int | None,
131152
fetched_at: int,
132153
) -> bool:
133154
with self.connect() as connection:
134155
cursor = connection.execute(
135156
UPDATE_REACTIONS,
136-
(up, down, locked, github_updated_at, fetched_at, node_id),
157+
(up, down, upvotes, locked, github_updated_at, fetched_at, node_id),
137158
)
159+
resource = connection.execute(
160+
"SELECT resource_id FROM discussions WHERE github_node_id = ?", (node_id,)
161+
).fetchone()
162+
if resource is not None:
163+
connection.execute("DELETE FROM reaction_counts WHERE resource_id = ?", (resource[0],))
164+
connection.executemany(
165+
"INSERT INTO reaction_counts (resource_id, reaction, count) VALUES (?, ?, ?)",
166+
[(resource[0], name, count) for name, count in reactions.items()],
167+
)
138168
return cursor.rowcount == 1
139169

140170
def adjust_reactions(

‎src/feedback/service/reaction_cache.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,21 @@ async def refresh(
5252
parsed = _parse_node(node)
5353
if parsed is None:
5454
continue
55-
node_id, up, down, locked, updated_at = parsed
55+
node_id, up, down, upvotes, reactions, locked, updated_at = parsed
5656
refreshed += database.update_reactions(
5757
node_id=node_id,
5858
up=up,
5959
down=down,
60+
upvotes=upvotes,
61+
reactions=reactions,
6062
locked=locked,
6163
github_updated_at=updated_at,
6264
fetched_at=fetched_at,
6365
)
6466
return refreshed
6567

6668

67-
def _parse_node(value: object) -> tuple[str, int, int, bool, int | None] | None:
69+
def _parse_node(value: object) -> tuple[str, int, int, int, dict[str, int], bool, int | None] | None:
6870
if value is None:
6971
return None
7072
if not isinstance(value, dict):
@@ -94,4 +96,11 @@ def _parse_node(value: object) -> tuple[str, int, int, bool, int | None] | None:
9496
updated_at = int(datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp())
9597
except ValueError as exc:
9698
raise GitHubError("github_malformed_response") from exc
97-
return node_id, counts.get("THUMBS_UP", 0), counts.get("THUMBS_DOWN", 0), locked, updated_at
99+
upvotes = value.get("upvoteCount", 0)
100+
if isinstance(upvotes, bool) or not isinstance(upvotes, int) or upvotes < 0:
101+
raise GitHubError("github_malformed_response")
102+
return (
103+
node_id, counts.get("THUMBS_UP", 0), counts.get("THUMBS_DOWN", 0), upvotes,
104+
{name: count for name, count in counts.items() if name != "THUMBS_UP" and name != "THUMBS_DOWN"},
105+
locked, updated_at,
106+
)

0 commit comments

Comments
 (0)