Skip to content

Commit fdf9252

Browse files
committed
feat(playmatch): persist suggestion cards in Redis across restarts
1 parent f86b045 commit fdf9252

9 files changed

Lines changed: 451 additions & 124 deletions

File tree

Cargo.lock

Lines changed: 141 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ poise = { git = "https://github.com/serenity-rs/poise", branch = "serenity-next"
3030
# playmatch
3131
playmatch_client = { path = "playmatch_client" }
3232
governor = "^0.10"
33+
redis = { version = "^1.0", features = ["tokio-comp", "connection-manager"] }
3334

3435
# commands
3536
unicode-width = "^0.2"

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Every response is rendered with Discord's Components V2 (top-level `Container`,
4242
| `DISCORD_TOKEN` | yes | Bot token from the Discord Developer Portal |
4343
| `PLAYMATCH_API_AUTH` | yes | Bearer token for the Playmatch API |
4444
| `PLAYMATCH_API_URL` | no | Playmatch base URL, defaults to `https://playmatch.retrorealm.dev` |
45+
| `REDIS_URL` | yes | Redis connection string (e.g. `redis://localhost:6379`), used to persist posted suggestion cards across restarts |
4546
| `DISCORD_STATUS` | no | Activity kind (`playing`, `listening`, `watching`, `competing`) |
4647
| `DISCORD_STATUS_NAME` | no | Activity text shown next to the kind |
4748
| `DISCORD_RETROREALM_SERVER_ID` | for guild commands | Guild id used when registering guild-scoped commands |

src/abstraction/command.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ pub type CheckResult = Result<bool, CommandError>;
1818
pub struct CommandData {
1919
pub client: reqwest::Client,
2020
pub playmatch_client: Arc<crate::abstraction::playmatch_client::PlaymatchClient>,
21+
pub suggestion_store: Arc<crate::abstraction::suggestion_store::SuggestionStore>,
2122
}
2223

23-
impl Default for CommandData {
24-
fn default() -> Self {
24+
impl CommandData {
25+
pub async fn new() -> anyhow::Result<Self> {
2526
let mut headers = HeaderMap::new();
2627
headers.insert(
2728
"Authorization",
@@ -50,12 +51,17 @@ impl Default for CommandData {
5051
client.clone(),
5152
);
5253

53-
Self {
54+
let redis_url = env::var("REDIS_URL").expect("missing REDIS_URL");
55+
let suggestion_store =
56+
crate::abstraction::suggestion_store::SuggestionStore::connect(&redis_url).await?;
57+
58+
Ok(Self {
5459
client,
5560
playmatch_client: Arc::new(crate::abstraction::playmatch_client::PlaymatchClient::new(
5661
inner,
5762
)),
58-
}
63+
suggestion_store: Arc::new(suggestion_store),
64+
})
5965
}
6066
}
6167

src/abstraction/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub mod components_v2;
44
pub mod playmatch;
55
pub mod playmatch_client;
66
pub mod providers;
7+
pub mod suggestion_store;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
use std::collections::HashMap;
2+
use std::str::FromStr;
3+
4+
use redis::AsyncCommands;
5+
use redis::aio::ConnectionManager;
6+
use serenity::all::MessageId;
7+
use uuid::Uuid;
8+
9+
pub type Error = redis::RedisError;
10+
11+
const KEY_PREFIX: &str = "playmatch:suggestion:";
12+
13+
fn key_for(uuid: Uuid) -> String {
14+
format!("{KEY_PREFIX}{uuid}")
15+
}
16+
17+
/// Persistent map of suggestion UUID → Discord message id. Backed by Redis so the bot
18+
/// can restart without re-posting cards or losing track of already-posted ones.
19+
pub struct SuggestionStore {
20+
conn: ConnectionManager,
21+
}
22+
23+
impl SuggestionStore {
24+
pub async fn connect(url: &str) -> Result<Self, Error> {
25+
let client = redis::Client::open(url)?;
26+
let conn = ConnectionManager::new(client).await?;
27+
Ok(Self { conn })
28+
}
29+
30+
pub async fn mark_posted(&self, uuid: Uuid, message_id: MessageId) -> Result<(), Error> {
31+
let mut c = self.conn.clone();
32+
c.set::<_, _, ()>(key_for(uuid), message_id.get()).await
33+
}
34+
35+
pub async fn get(&self, uuid: Uuid) -> Result<Option<MessageId>, Error> {
36+
let mut c = self.conn.clone();
37+
let raw: Option<u64> = c.get(key_for(uuid)).await?;
38+
Ok(raw.map(MessageId::new))
39+
}
40+
41+
pub async fn remove(&self, uuid: Uuid) -> Result<(), Error> {
42+
let mut c = self.conn.clone();
43+
c.del::<_, ()>(key_for(uuid)).await
44+
}
45+
46+
pub async fn list_all(&self) -> Result<HashMap<Uuid, MessageId>, Error> {
47+
let mut conn = self.conn.clone();
48+
let pattern = format!("{KEY_PREFIX}*");
49+
let mut cursor = 0u64;
50+
let mut keys: Vec<String> = Vec::new();
51+
loop {
52+
let (next_cursor, batch): (u64, Vec<String>) = redis::cmd("SCAN")
53+
.arg(cursor)
54+
.arg("MATCH")
55+
.arg(&pattern)
56+
.arg("COUNT")
57+
.arg(100)
58+
.query_async(&mut conn)
59+
.await?;
60+
keys.extend(batch);
61+
if next_cursor == 0 {
62+
break;
63+
}
64+
cursor = next_cursor;
65+
}
66+
67+
if keys.is_empty() {
68+
return Ok(HashMap::new());
69+
}
70+
71+
let values: Vec<Option<u64>> = conn.mget(&keys).await?;
72+
let mut result = HashMap::with_capacity(keys.len());
73+
for (key, value) in keys.into_iter().zip(values) {
74+
let Some(uuid_str) = key.strip_prefix(KEY_PREFIX) else {
75+
continue;
76+
};
77+
let Ok(uuid) = Uuid::from_str(uuid_str) else {
78+
continue;
79+
};
80+
let Some(message_id) = value else {
81+
continue;
82+
};
83+
result.insert(uuid, MessageId::new(message_id));
84+
}
85+
Ok(result)
86+
}
87+
88+
pub async fn is_empty(&self) -> Result<bool, Error> {
89+
Ok(self.list_all().await?.is_empty())
90+
}
91+
}

0 commit comments

Comments
 (0)