Skip to content

Commit

Permalink
Support automated Firebase availability editing.
Browse files Browse the repository at this point in the history
  • Loading branch information
awaitlink committed Apr 4, 2024
1 parent 90bb3a9 commit c2ac109
Show file tree
Hide file tree
Showing 12 changed files with 345 additions and 41 deletions.
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ tracing = { version = "0.1", features = [
tracing-subscriber = "0.3"
tracing-wasm = "0.2"
factorial = "0.4"
subtle = "2.5"

[dev-dependencies]
test-case = "3.1"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ To run this bot, do the following:
`DISCORD_UPDATES_MENTION_ROLE` | Role ID to mention about new versions in Discord.
`DISCORD_SERVER_UPDATES_MENTION_ROLE` | Role ID to mention about new Server versions in Discord.
`DISCORD_ERRORS_MENTION_ROLE` | Role ID to mention about errors in Discord.
`ACCESS_TOKEN` | Token that can be used to run the bot on demand or tell the latest Android Firebase version to it via API.
1. In the KV namespace(s) you created, manually create a key-value pair with the key `state` and a value like:
Expand Down Expand Up @@ -102,7 +103,7 @@ Run the following command to deploy the bot:
wrangler publish -e production
```
The `production` variant is configured by default to run every 10 minutes. For the `staging` variant, you have to invoke it manually by visiting its URL (that looks like `signalupdates-bot-staging.<your-workers-subdomain>.workers.dev`).
The `production` variant is configured by default to run every 10 minutes. For the `staging` variant, you have to invoke it manually by visiting its URL (that looks like `signalupdates-bot-staging.<your-workers-subdomain>.workers.dev/ACCESS_TOKEN/run`).
## Acknowledgements
Expand Down
53 changes: 53 additions & 0 deletions src/discourse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,59 @@ pub async fn post(
}
}

pub async fn get_post(post_id: u64, api_key: &str) -> anyhow::Result<Post> {
let url = Url::parse(&format!(
"https://community.signalusers.org/posts/{post_id}.json"
))
.context("could not parse URL")?;

let request = network::create_request(
url,
Method::Get,
ContentType::ApplicationJson,
ContentType::ApplicationJson,
None,
Some(api_key),
)?;
let post: ApiResponse<Post> = network::get_json_from_request(request).await?;

Ok(match post {
ApiResponse::Ok(post) => post,
ApiResponse::Err(error) => bail!("error = {error:?}"),
ApiResponse::Unknown(value) => bail!("unexpected response = {value:?}"),
})
}

pub async fn edit_post(post_id: u64, api_key: &str, raw: &str) -> anyhow::Result<WrappedPost> {
let url = Url::parse(&format!(
"https://community.signalusers.org/posts/{post_id}.json"
))
.context("could not parse URL")?;

let request = network::create_request(
url,
Method::Put,
ContentType::ApplicationJson,
ContentType::ApplicationJson,
Some(
json!({
"post": {
"raw": raw
}
})
.to_string(),
),
Some(api_key),
)?;
let post: ApiResponse<WrappedPost> = network::get_json_from_request(request).await?;

Ok(match post {
ApiResponse::Ok(post) => post,
ApiResponse::Err(error) => bail!("error = {error:?}"),
ApiResponse::Unknown(value) => bail!("unexpected response = {value:?}"),
})
}

pub async fn get_replies_to_post(post_id: u64) -> anyhow::Result<Vec<Post>> {
let url = Url::parse(&format!(
"https://community.signalusers.org/posts/{post_id}/replies.json"
Expand Down
9 changes: 9 additions & 0 deletions src/discourse/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub struct PostStream {
pub posts: Vec<Post>,
}

#[derive(Deserialize, Debug, PartialEq, Eq)]
pub struct WrappedPost {
pub post: Post,
}

#[derive(Deserialize, Debug, PartialEq, Eq)]
pub struct Post {
pub id: u64,
Expand All @@ -34,6 +39,8 @@ pub struct Post {
pub post_number: u64,

pub user_id: u64,

pub raw: String,
}

#[derive(Deserialize, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -106,6 +113,7 @@ mod tests {
"topic_id": 0,
"post_number": 0,
"user_id": 0,
"raw": "content",
}};

assert_eq!(
Expand All @@ -115,6 +123,7 @@ mod tests {
topic_id: 0,
post_number: 0,
user_id: 0,
raw: String::from("content"),
}))
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub trait EnvExt {

fn is_dry_run(&self) -> anyhow::Result<bool>;
fn enabled_platforms(&self) -> anyhow::Result<Vec<Platform>>;

fn access_token(&self) -> anyhow::Result<String>;
}

impl EnvExt for Env {
Expand Down Expand Up @@ -103,6 +105,10 @@ impl EnvExt for Env {
fn enabled_platforms(&self) -> anyhow::Result<Vec<Platform>> {
get_env_string(self, Var, "ENABLED_PLATFORMS").map(|s| filter_platforms(&s))
}

fn access_token(&self) -> anyhow::Result<String> {
get_env_string(self, Var, "ACCESS_TOKEN")
}
}

#[cfg(test)]
Expand Down
6 changes: 6 additions & 0 deletions src/github/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ impl Tag {
.map_err(|e| anyhow!(e.to_string()))
.context("could not parse version from tag")
}

pub fn from_exact_version_string(version: &str) -> Self {
Self {
name: format!("v{version}"),
}
}
}

#[cfg(test)]
Expand Down
137 changes: 127 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use anyhow::Context;
use chrono::prelude::*;
use github::Tag;
use semver::Version;
use subtle::ConstantTimeEq;
use worker::{event, Env, ScheduleContext, ScheduledEvent};

use crate::{
Expand All @@ -26,43 +27,97 @@ use crate::{
Completeness, LocalizationChange, LocalizationChangeCollection, LocalizationChanges,
},
logging::Logger,
platform::{android::BuildConfiguration, Platform},
platform::{
android::BuildConfiguration,
Platform::{self, *},
},
state::{PostInformation, StateController},
};

const POSTING_DELAY_MILLISECONDS: u64 = 3000;

enum Mode {
MakeNewPostIfPossible,
EditExistingAndroidPostIfNeeded { latest_available: Tag },
}

enum PlatformCheckOutcome {
WaitingForApproval,
LatestVersionIsAlreadyPosted,
NewTopicNotFound,
PostedCommits,
}

use Mode::*;
use PlatformCheckOutcome::*;

// Used for debugging, to manually trigger the bot outside of schedule.
#[event(fetch)]
pub async fn fetch(
_req: worker::Request,
req: worker::Request,
env: Env,
_ctx: worker::Context,
) -> worker::Result<worker::Response> {
main(&env).await;
worker::Response::empty()
panic_hook::set_panic_hook();

let router = worker::Router::new();

router
.get_async("/:token/firebase/:version", |_req, ctx| async move {
if let Some(token) = ctx.param("token") {
if let Some(version) = ctx.param("version") {
if token
.as_bytes()
.ct_eq(ctx.env.access_token().unwrap().as_bytes())
.unwrap_u8()
== 1
{
main(
&ctx.env,
EditExistingAndroidPostIfNeeded {
latest_available: Tag::from_exact_version_string(version),
},
)
.await;
}
}
}

worker::Response::empty()
})
.get_async("/:token/run", |_req, ctx| async move {
if let Some(token) = ctx.param("token") {
if token
.as_bytes()
.ct_eq(ctx.env.access_token().unwrap().as_bytes())
.unwrap_u8()
== 1
{
main(&ctx.env, MakeNewPostIfPossible).await;
}
}

worker::Response::empty()
})
.run(req, env)
.await
}

#[event(scheduled)]
pub async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
main(&env).await;
main(&env, MakeNewPostIfPossible).await;
}

async fn main(env: &Env) {
panic_hook::set_panic_hook();

async fn main(env: &Env, mode: Mode) {
let logger = Logger::new();

match check_all_platforms(env, &logger).await {
let result = match mode {
MakeNewPostIfPossible => check_all_platforms(env, &logger).await,
EditExistingAndroidPostIfNeeded { latest_available } => {
edit_existing_android_post_if_needed(env, latest_available).await
}
};

match result {
Err(error) => {
tracing::error!(?error);

Expand All @@ -80,6 +135,60 @@ async fn main(env: &Env) {
}
}

async fn edit_existing_android_post_if_needed(
env: &Env,
latest_available: Tag,
) -> anyhow::Result<()> {
tracing::debug!(?latest_available, "the god from beyond told us the new latest available version in firebase. let's see what we can do");
let latest_available_v = latest_available
.to_version()
.context("couldn't make version out of new latest available one")?;

let mut state_controller = state::StateController::from_kv(env).await?;
let previous_v = state_controller
.most_recent_android_firebase_version_tag()
.to_version()
.context("couldn't make version out of previous one")?;

if latest_available_v > previous_v {
tracing::debug!("latest_available > previous. good");

let platform_state = &state_controller.platform_state(Android);
tracing::debug!(?platform_state.last_posted_tag);

if platform_state.last_posted_tag == latest_available {
tracing::debug!("last_posted_tag == latest_available. will edit post");

if let Some(PostInformation { id, .. }) = platform_state.last_post {
let discourse_api_key = env.discourse_api_key()?;

tracing::trace!("getting existing post...");
let existing_post = discourse::get_post(id, &discourse_api_key).await?;
let new_raw = existing_post.raw.replace(
Android.availability_notice(false),
Android.availability_notice(true),
);

discourse::edit_post(id, &discourse_api_key, &new_raw).await?;
tracing::trace!("probably edited post!");
} else {
tracing::warn!("there's no post information but there is a last posted version!");
}
} else {
tracing::debug!("last_posted_tag != latest_available. will not edit any posts");
// TODO: store more previous posts and edit them?
}

tracing::trace!("updating KV...");
state_controller.set_firebase(latest_available).await?;
tracing::trace!("updated KV");
} else {
tracing::debug!("latest_available <= previous. ignoring");
}

Ok(())
}

async fn check_all_platforms(env: &Env, logger: &Logger) -> anyhow::Result<()> {
let now = DateTime::from(utils::now());
tracing::debug!("now = {} (seconds: {})", now.to_rfc3339(), now.timestamp());
Expand Down Expand Up @@ -398,11 +507,19 @@ async fn check_platform(
None
};

let available = match platform {
Android => {
state_controller.most_recent_android_firebase_version_tag() == new_tag
}
Ios | Desktop | Server => false, // dummy value,
};

let post = markdown::Post::new(
platform,
old_tag,
new_tag,
new_build_configuration,
available,
commits,
unfiltered_commits_len,
LocalizationChangeCollection {
Expand Down
Loading

0 comments on commit c2ac109

Please sign in to comment.