From 4663167f6597e512bb8ce38724ec34e878f161ac Mon Sep 17 00:00:00 2001 From: Matt Grogan Date: Mon, 1 Jul 2024 16:45:49 +0100 Subject: [PATCH] Move ownership of chit balance and streak to users (#5972) --- backend/canisters/user/CHANGELOG.md | 1 + backend/canisters/user/api/can.did | 19 ++++ backend/canisters/user/api/src/main.rs | 1 + .../user/api/src/queries/initial_state.rs | 4 + .../canisters/user/api/src/queries/updates.rs | 4 + .../user/api/src/updates/claim_daily_chit.rs | 19 ++++ backend/canisters/user/api/src/updates/mod.rs | 1 + backend/canisters/user/impl/src/lib.rs | 86 ++++++++++++++--- .../user/impl/src/lifecycle/post_upgrade.rs | 44 +++++---- backend/canisters/user/impl/src/model/chit.rs | 31 +----- .../user/impl/src/queries/initial_state.rs | 5 + .../user/impl/src/queries/updates.rs | 13 ++- .../impl/src/updates/c2c_notify_events.rs | 37 +++++--- .../c2c_notify_user_canister_events.rs | 11 ++- .../user/impl/src/updates/claim_daily_chit.rs | 94 +++++++++++++++++++ .../canisters/user/impl/src/updates/mod.rs | 1 + .../user/impl/src/updates/send_message.rs | 2 +- .../user/impl/src/updates/set_avatar.rs | 2 +- .../user/impl/src/updates/set_bio.rs | 2 +- backend/canisters/user_index/CHANGELOG.md | 11 ++- .../api/src/updates/c2c_notify_chit.rs | 16 ++++ .../user_index/api/src/updates/mod.rs | 1 + .../user_index/c2c_client/src/lib.rs | 1 + backend/canisters/user_index/impl/src/lib.rs | 44 ++++++++- .../user_index/impl/src/model/user.rs | 20 ++++ .../user_index/impl/src/model/user_map.rs | 26 +++++ .../impl/src/queries/http_request.rs | 26 ++++- .../impl/src/updates/c2c_notify_chit.rs | 29 ++++++ .../user_index/impl/src/updates/mod.rs | 1 + backend/libraries/utils/src/streak.rs | 8 ++ 30 files changed, 476 insertions(+), 84 deletions(-) create mode 100644 backend/canisters/user/api/src/updates/claim_daily_chit.rs create mode 100644 backend/canisters/user/impl/src/updates/claim_daily_chit.rs create mode 100644 backend/canisters/user_index/api/src/updates/c2c_notify_chit.rs create mode 100644 backend/canisters/user_index/impl/src/updates/c2c_notify_chit.rs diff --git a/backend/canisters/user/CHANGELOG.md b/backend/canisters/user/CHANGELOG.md index 13d375906b..4d29816768 100644 --- a/backend/canisters/user/CHANGELOG.md +++ b/backend/canisters/user/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Added `achievements` ([#5962](https://github.com/open-chat-labs/open-chat/pull/5962)) +- Maintains chit balance and streak and notifies user_index ([#5972](https://github.com/open-chat-labs/open-chat/pull/5972)) ## [[2.0.1213](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1213-user)] - 2024-06-24 diff --git a/backend/canisters/user/api/can.did b/backend/canisters/user/api/can.did index 94cfea3889..0fb0d668a1 100644 --- a/backend/canisters/user/api/can.did +++ b/backend/canisters/user/api/can.did @@ -298,6 +298,16 @@ type JoinVideoCallResponse = variant { ChatNotFound; }; +type ClaimDailyChitResponse = variant { + Success : record { + chit_earned : nat32; + chit_balance : int32; + streak : nat16; + next_claim : TimestampMillis; + }; + AlreadyClaimed : TimestampMillis; +}; + type TipMessageArgs = record { chat : Chat; recipient : UserId; @@ -867,6 +877,10 @@ type InitialStateResponse = variant { local_user_index_canister_id : CanisterId; achievements : vec ChitEarned; achievements_last_seen : TimestampMillis; + chit_balance : int32; + streak : nat16; + streak_ends : TimestampMillis; + next_daily_claim : TimestampMillis; }; }; @@ -923,6 +937,10 @@ type UpdatesResponse = variant { suspended : opt bool; achievements : vec ChitEarned; achievements_last_seen : opt TimestampMillis; + chit_balance : int32; + streak : nat16; + streak_ends : TimestampMillis; + next_daily_claim : TimestampMillis; }; SuccessNoUpdates; }; @@ -1209,6 +1227,7 @@ service : { start_video_call : (StartVideoCallArgs) -> (StartVideoCallResponse); join_video_call : (JoinVideoCallArgs) -> (JoinVideoCallResponse); end_video_call : (EndVideoCallArgs) -> (EndVideoCallResponse); + claim_daily_chit : (EmptyArgs) -> (ClaimDailyChitResponse); events : (EventsArgs) -> (EventsResponse) query; events_by_index : (EventsByIndexArgs) -> (EventsResponse) query; diff --git a/backend/canisters/user/api/src/main.rs b/backend/canisters/user/api/src/main.rs index b20c7ee4d7..7e1e845de7 100644 --- a/backend/canisters/user/api/src/main.rs +++ b/backend/canisters/user/api/src/main.rs @@ -28,6 +28,7 @@ fn main() { generate_candid_method!(user, block_user, update); generate_candid_method!(user, cancel_message_reminder, update); generate_candid_method!(user, cancel_p2p_swap, update); + generate_candid_method!(user, claim_daily_chit, update); generate_candid_method!(user, create_community, update); generate_candid_method!(user, create_group, update); generate_candid_method!(user, delete_community, update); diff --git a/backend/canisters/user/api/src/queries/initial_state.rs b/backend/canisters/user/api/src/queries/initial_state.rs index 98f2360616..061b85cdbc 100644 --- a/backend/canisters/user/api/src/queries/initial_state.rs +++ b/backend/canisters/user/api/src/queries/initial_state.rs @@ -23,6 +23,10 @@ pub struct SuccessResult { pub local_user_index_canister_id: CanisterId, pub achievements: Vec, pub achievements_last_seen: TimestampMillis, + pub chit_balance: i32, + pub streak: u16, + pub streak_ends: TimestampMillis, + pub next_daily_claim: TimestampMillis, } #[derive(CandidType, Serialize, Deserialize, Debug)] diff --git a/backend/canisters/user/api/src/queries/updates.rs b/backend/canisters/user/api/src/queries/updates.rs index 47232e8679..98268038b3 100644 --- a/backend/canisters/user/api/src/queries/updates.rs +++ b/backend/canisters/user/api/src/queries/updates.rs @@ -32,6 +32,10 @@ pub struct SuccessResult { pub pin_number_settings: OptionUpdate, pub achievements: Vec, pub achievements_last_seen: Option, + pub chit_balance: i32, + pub streak: u16, + pub streak_ends: TimestampMillis, + pub next_daily_claim: TimestampMillis, } #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] diff --git a/backend/canisters/user/api/src/updates/claim_daily_chit.rs b/backend/canisters/user/api/src/updates/claim_daily_chit.rs new file mode 100644 index 0000000000..8af1d3d0ca --- /dev/null +++ b/backend/canisters/user/api/src/updates/claim_daily_chit.rs @@ -0,0 +1,19 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use types::{Empty, TimestampMillis}; + +pub type Args = Empty; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success(SuccessResult), + AlreadyClaimed(TimestampMillis), +} + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct SuccessResult { + pub chit_earned: u32, + pub chit_balance: i32, + pub streak: u16, + pub next_claim: TimestampMillis, +} diff --git a/backend/canisters/user/api/src/updates/mod.rs b/backend/canisters/user/api/src/updates/mod.rs index cb6977a0df..050959d52e 100644 --- a/backend/canisters/user/api/src/updates/mod.rs +++ b/backend/canisters/user/api/src/updates/mod.rs @@ -21,6 +21,7 @@ pub mod c2c_set_user_suspended; pub mod c2c_vote_on_proposal; pub mod cancel_message_reminder; pub mod cancel_p2p_swap; +pub mod claim_daily_chit; pub mod create_community; pub mod create_group; pub mod delete_community; diff --git a/backend/canisters/user/impl/src/lib.rs b/backend/canisters/user/impl/src/lib.rs index fd85b4c94a..f48fd87c6e 100644 --- a/backend/canisters/user/impl/src/lib.rs +++ b/backend/canisters/user/impl/src/lib.rs @@ -22,6 +22,7 @@ use notifications_canister::c2c_push_notification; use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; use std::cell::RefCell; +use std::cmp::max; use std::collections::HashSet; use std::ops::Deref; use std::time::Duration; @@ -33,7 +34,8 @@ use user_canister::{NamedAccount, UserCanisterEvent}; use utils::canister_event_sync_queue::CanisterEventSyncQueue; use utils::env::Environment; use utils::regular_jobs::RegularJobs; -use utils::time::MINUTE_IN_MS; +use utils::streak::Streak; +use utils::time::{today, tomorrow, MINUTE_IN_MS}; mod crypto; mod governance_clients; @@ -146,10 +148,11 @@ impl RuntimeState { } pub fn metrics(&self) -> Metrics { + let now = self.env.now(); Metrics { heap_memory_used: utils::memory::heap(), stable_memory_used: utils::memory::stable(), - now: self.env.now(), + now, cycles_balance: self.env.cycles_balance(), wasm_version: WASM_VERSION.with_borrow(|v| **v), git_commit_id: utils::git::git_commit_id().to_string(), @@ -171,16 +174,11 @@ impl RuntimeState { escrow: self.data.escrow_canister_id, icp_ledger: Cryptocurrency::InternetComputer.ledger_canister_id().unwrap(), }, - } - } - - pub fn insert_achievement(&mut self, achievement: Achievement) { - if self.data.achievements.insert(achievement.clone()) { - self.data.chit_events.push(ChitEarned { - amount: achievement.chit_reward() as i32, - timestamp: self.env.now(), - reason: ChitEarnedReason::Achievement(achievement), - }); + chit_balance: self.data.chit_balance.value, + streak: self.data.streak.days(now), + streak_ends: self.data.streak.ends(), + next_daily_claim: if self.data.streak.can_claim(now) { today(now) } else { tomorrow(now) }, + achievements: self.data.achievements.iter().cloned().collect(), } } } @@ -225,6 +223,10 @@ struct Data { pub btc_address: Option, pub chit_events: ChitEarnedEvents, #[serde(default)] + pub chit_balance: Timestamped, + #[serde(default)] + pub streak: Streak, + #[serde(default)] pub achievements: HashSet, #[serde(default)] pub achievements_last_seen: TimestampMillis, @@ -286,6 +288,8 @@ impl Data { pin_number: PinNumber::default(), btc_address: None, chit_events: ChitEarnedEvents::default(), + chit_balance: Timestamped::default(), + streak: Streak::default(), achievements: HashSet::new(), achievements_last_seen: 0, rng_seed: [0; 32], @@ -331,6 +335,59 @@ impl Data { timer_jobs.enqueue_job(TimerJob::RemoveExpiredEvents(RemoveExpiredEventsJob), expiry, now); } } + + pub fn award_achievement_and_notify(&mut self, achievement: Achievement, now: TimestampMillis) { + if self.award_achievement(achievement, now) { + self.notify_user_index_of_chit(now); + } + } + + pub fn award_achievement(&mut self, achievement: Achievement, now: TimestampMillis) -> bool { + if self.achievements.insert(achievement.clone()) { + let amount = achievement.chit_reward() as i32; + self.chit_events.push(ChitEarned { + amount, + timestamp: now, + reason: ChitEarnedReason::Achievement(achievement), + }); + self.chit_balance = Timestamped::new(self.chit_balance.value + amount, now); + true + } else { + false + } + } + + pub fn notify_user_index_of_chit(&mut self, now: TimestampMillis) { + let args = user_index_canister::c2c_notify_chit::Args { + timestamp: now, + chit_balance: self.chit_balance.value, + streak: self.streak.days(now), + streak_ends: self.streak.ends(), + }; + + self.fire_and_forget_handler.send( + self.user_index_canister_id, + "c2c_notify_chit_msgpack".to_string(), + msgpack::serialize_then_unwrap(args), + ); + } + + pub fn init_streak_and_chit_balance(&mut self, now: TimestampMillis) -> u16 { + let mut max_streak: u16 = 0; + self.chit_balance = Timestamped::new(0, now); + + for event in self.chit_events.iter() { + self.chit_balance = Timestamped::new(self.chit_balance.value + event.amount, now); + + let is_daily_claim = matches!(event.reason, ChitEarnedReason::DailyClaim); + + if is_daily_claim && self.streak.claim(event.timestamp) { + max_streak = max(max_streak, self.streak.days(event.timestamp)) + } + } + + max_streak + } } #[derive(Serialize, Debug)] @@ -351,6 +408,11 @@ pub struct Metrics { pub video_call_operators: Vec, pub timer_jobs: u32, pub canister_ids: CanisterIds, + pub chit_balance: i32, + pub streak: u16, + pub streak_ends: TimestampMillis, + pub next_daily_claim: TimestampMillis, + pub achievements: Vec, } fn run_regular_jobs() { diff --git a/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs index 06fe026a4c..c6099ff8f5 100644 --- a/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs @@ -39,7 +39,7 @@ fn post_upgrade(args: Args) { } }); - mutate_state(initialize_achievements); + mutate_state(initialize_chit_and_achievements); } fn mark_user_canister_empty() { @@ -53,18 +53,18 @@ fn mark_user_canister_empty() { }) } -fn initialize_achievements(state: &mut RuntimeState) { - let longest_streak = state.data.chit_events.init_streak(); - +fn initialize_chit_and_achievements(state: &mut RuntimeState) { let me: UserId = state.env.canister_id().into(); let now = state.env.now(); + let longest_streak = state.data.init_streak_and_chit_balance(now); + if state.data.group_chats.len() > 0 { - state.insert_achievement(Achievement::JoinedGroup); + state.data.award_achievement(Achievement::JoinedGroup, now); } if state.data.communities.len() > 0 { - state.insert_achievement(Achievement::JoinedCommunity); + state.data.award_achievement(Achievement::JoinedCommunity, now); } if state.data.direct_chats.iter().any(|c| { @@ -73,7 +73,7 @@ fn initialize_achievements(state: &mut RuntimeState) { .iter_latest_messages(None) .any(|m| m.event.sender == me) }) { - state.insert_achievement(Achievement::SentDirectMessage); + state.data.award_achievement(Achievement::SentDirectMessage, now); } if state.data.direct_chats.iter().any(|c| { @@ -82,42 +82,52 @@ fn initialize_achievements(state: &mut RuntimeState) { .iter_latest_messages(None) .any(|m| m.event.sender == c.them) }) { - state.insert_achievement(Achievement::ReceivedDirectMessage); + state.data.award_achievement(Achievement::ReceivedDirectMessage, now); } if state.data.avatar.value.is_some() { - state.insert_achievement(Achievement::SetAvatar); + state.data.award_achievement(Achievement::SetAvatar, now); } if !state.data.bio.value.is_empty() { - state.insert_achievement(Achievement::SetBio); + state.data.award_achievement(Achievement::SetBio, now); } if state.data.display_name.value.is_some() { - state.insert_achievement(Achievement::SetDisplayName); + state.data.award_achievement(Achievement::SetDisplayName, now); } if let Some(diamond_expires) = state.data.diamond_membership_expires_at { - state.insert_achievement(Achievement::UpgradedToDiamond); + state.data.award_achievement(Achievement::UpgradedToDiamond, now); if (diamond_expires - now) > (5 * 365 * DAY_IN_MS) { - state.insert_achievement(Achievement::UpgradedToGoldDiamond); + state.data.award_achievement(Achievement::UpgradedToGoldDiamond, now); } } if longest_streak >= 3 { - state.insert_achievement(Achievement::Streak3); + state.data.award_achievement(Achievement::Streak3, now); } if longest_streak >= 7 { - state.insert_achievement(Achievement::Streak7); + state.data.award_achievement(Achievement::Streak7, now); } if longest_streak >= 14 { - state.insert_achievement(Achievement::Streak14); + state.data.award_achievement(Achievement::Streak14, now); } if longest_streak >= 30 { - state.insert_achievement(Achievement::Streak30); + state.data.award_achievement(Achievement::Streak30, now); + } + + if state.data.chit_balance.value > 0 { + ic_cdk_timers::set_timer(Duration::ZERO, notify_user_index_of_chit); } } + +fn notify_user_index_of_chit() { + mutate_state(|state| { + state.data.notify_user_index_of_chit(state.env.now()); + }) +} diff --git a/backend/canisters/user/impl/src/model/chit.rs b/backend/canisters/user/impl/src/model/chit.rs index 755b5c30cd..b7b464ba29 100644 --- a/backend/canisters/user/impl/src/model/chit.rs +++ b/backend/canisters/user/impl/src/model/chit.rs @@ -1,30 +1,12 @@ use serde::{Deserialize, Serialize}; -use std::cmp::max; use types::{ChitEarned, ChitEarnedReason, TimestampMillis}; -use utils::streak::Streak; #[derive(Serialize, Deserialize, Default)] pub struct ChitEarnedEvents { events: Vec, - #[serde(default)] - streak: Streak, } impl ChitEarnedEvents { - pub fn init_streak(&mut self) -> u16 { - let mut max_streak: u16 = 0; - - for event in self.events.iter() { - if matches!(event.reason, ChitEarnedReason::DailyClaim) { - self.streak.claim(event.timestamp); - - max_streak = max(max_streak, self.streak.days(event.timestamp)) - } - } - - max_streak - } - pub fn push(&mut self, event: ChitEarned) { let mut sort = false; @@ -34,10 +16,6 @@ impl ChitEarnedEvents { } } - if matches!(event.reason, ChitEarnedReason::DailyClaim) { - self.streak.claim(event.timestamp); - } - self.events.push(event); if sort { @@ -74,6 +52,10 @@ impl ChitEarnedEvents { (page, self.events.len() as u32) } + pub fn iter(&self) -> impl Iterator { + self.events.iter() + } + pub fn achievements(&self, since: Option) -> Vec { self.events .iter() @@ -91,10 +73,6 @@ impl ChitEarnedEvents { .take_while(|e| e.timestamp > since) .any(|e| matches!(e.reason, ChitEarnedReason::Achievement(_))) } - - pub fn streak(&self, now: TimestampMillis) -> u16 { - self.streak.days(now) - } } #[cfg(test)] @@ -174,7 +152,6 @@ mod tests { fn init_test_data() -> ChitEarnedEvents { ChitEarnedEvents { - streak: Streak::default(), events: vec![ ChitEarned { amount: 200, diff --git a/backend/canisters/user/impl/src/queries/initial_state.rs b/backend/canisters/user/impl/src/queries/initial_state.rs index 3fd2391fc5..19761de618 100644 --- a/backend/canisters/user/impl/src/queries/initial_state.rs +++ b/backend/canisters/user/impl/src/queries/initial_state.rs @@ -3,6 +3,7 @@ use crate::{read_state, RuntimeState}; use ic_cdk::query; use types::UserId; use user_canister::initial_state::{Response::*, *}; +use utils::time::{today, tomorrow}; #[query(guard = "caller_is_owner")] fn initial_state(_args: Args) -> Response { @@ -48,5 +49,9 @@ fn initial_state_impl(state: &RuntimeState) -> Response { local_user_index_canister_id: state.data.local_user_index_canister_id, achievements: state.data.chit_events.achievements(None), achievements_last_seen: state.data.achievements_last_seen, + chit_balance: state.data.chit_balance.value, + streak: state.data.streak.days(now), + streak_ends: state.data.streak.ends(), + next_daily_claim: if state.data.streak.can_claim(now) { today(now) } else { tomorrow(now) }, }) } diff --git a/backend/canisters/user/impl/src/queries/updates.rs b/backend/canisters/user/impl/src/queries/updates.rs index e1fa9a1cdc..dd1909110c 100644 --- a/backend/canisters/user/impl/src/queries/updates.rs +++ b/backend/canisters/user/impl/src/queries/updates.rs @@ -3,6 +3,7 @@ use crate::{read_state, RuntimeState}; use ic_cdk::query; use types::{OptionUpdate, TimestampMillis, UserId}; use user_canister::updates::{Response::*, *}; +use utils::time::{today, tomorrow}; #[query(guard = "caller_is_owner")] fn updates(args: Args) -> Response { @@ -47,7 +48,8 @@ fn updates_impl(updates_since: TimestampMillis, state: &RuntimeState) -> Respons || state.data.favourite_chats.any_updated(updates_since) || state.data.communities.any_updated(updates_since) || state.data.chit_events.has_achievements_since(updates_since) - || state.data.achievements_last_seen > updates_since; + || state.data.achievements_last_seen > updates_since + || state.data.chit_balance.timestamp > updates_since; // Short circuit prior to calling `ic0.time()` so that caching works effectively if !has_any_updates { @@ -132,6 +134,11 @@ fn updates_impl(updates_since: TimestampMillis, state: &RuntimeState) -> Respons None }; + let chit_balance = state.data.chit_balance.value; + let streak = state.data.streak.days(now); + let next_daily_claim = if state.data.streak.can_claim(now) { today(now) } else { tomorrow(now) }; + let streak_ends = state.data.streak.ends(); + Success(SuccessResult { timestamp: now, username, @@ -146,5 +153,9 @@ fn updates_impl(updates_since: TimestampMillis, state: &RuntimeState) -> Respons pin_number_settings, achievements, achievements_last_seen, + chit_balance, + streak, + streak_ends, + next_daily_claim, }) } diff --git a/backend/canisters/user/impl/src/updates/c2c_notify_events.rs b/backend/canisters/user/impl/src/updates/c2c_notify_events.rs index c26e8d597f..807757d23e 100644 --- a/backend/canisters/user/impl/src/updates/c2c_notify_events.rs +++ b/backend/canisters/user/impl/src/updates/c2c_notify_events.rs @@ -21,15 +21,15 @@ fn c2c_notify_events_impl(args: Args, state: &mut RuntimeState) -> Response { } fn process_event(event: Event, state: &mut RuntimeState) { + let now = state.env.now(); + match event { Event::UsernameChanged(ev) => { - let now = state.env.now(); state.data.username = Timestamped::new(ev.username, now); } Event::DisplayNameChanged(ev) => { - let now = state.env.now(); state.data.display_name = Timestamped::new(ev.display_name, now); - state.insert_achievement(types::Achievement::SetDisplayName); + state.data.award_achievement_and_notify(Achievement::SetDisplayName, now); } Event::PhoneNumberConfirmed(ev) => { state.data.phone_is_verified = true; @@ -54,16 +54,14 @@ fn process_event(event: Event, state: &mut RuntimeState) { openchat_bot::send_message(message.content.into(), message.mentioned, false, state); } Event::UserJoinedGroup(ev) => { - let now = state.env.now(); state .data .group_chats .join(ev.chat_id, ev.local_user_index_canister_id, ev.latest_message_index, now); state.data.hot_group_exclusions.remove(&ev.chat_id, now); - state.insert_achievement(types::Achievement::JoinedGroup); + state.data.award_achievement_and_notify(Achievement::JoinedGroup, now); } Event::UserJoinedCommunityOrChannel(ev) => { - let now = state.env.now(); let (community, _) = state .data .communities @@ -80,13 +78,17 @@ fn process_event(event: Event, state: &mut RuntimeState) { .collect(), now, ); - state.insert_achievement(types::Achievement::JoinedCommunity); + state.data.award_achievement_and_notify(Achievement::JoinedCommunity, now); } Event::DiamondMembershipPaymentReceived(ev) => { - state.insert_achievement(types::Achievement::UpgradedToDiamond); + let mut awarded = state.data.award_achievement(Achievement::UpgradedToDiamond, now); if matches!(ev.duration, DiamondMembershipPlanDuration::Lifetime) { - state.insert_achievement(types::Achievement::UpgradedToGoldDiamond); + awarded = awarded || state.data.award_achievement(Achievement::UpgradedToGoldDiamond, now); + } + + if awarded { + state.data.notify_user_index_of_chit(now); } state.data.diamond_membership_expires_at = Some(ev.expires_at); @@ -100,31 +102,36 @@ fn process_event(event: Event, state: &mut RuntimeState) { ); } } + // TODO: LEGACY - delete this once the website has switched to calling the new user::claim_daily_chit endpoint Event::ChitEarned(ev) => { let timestamp = ev.timestamp; let is_daily_claim = matches!(ev.reason, ChitEarnedReason::DailyClaim); + state.data.chit_balance = Timestamped::new(state.data.chit_balance.value + ev.amount, now); + state.data.chit_events.push(*ev); - if is_daily_claim { - let streak = state.data.chit_events.streak(timestamp); + if is_daily_claim && state.data.streak.claim(timestamp) { + let streak = state.data.streak.days(timestamp); if streak >= 3 { - state.insert_achievement(Achievement::Streak3); + state.data.award_achievement(Achievement::Streak3, now); } if streak >= 7 { - state.insert_achievement(Achievement::Streak7); + state.data.award_achievement(Achievement::Streak7, now); } if streak >= 14 { - state.insert_achievement(Achievement::Streak14); + state.data.award_achievement(Achievement::Streak14, now); } if streak >= 30 { - state.insert_achievement(Achievement::Streak30); + state.data.award_achievement(Achievement::Streak30, now); } } + + state.data.notify_user_index_of_chit(now); } } } diff --git a/backend/canisters/user/impl/src/updates/c2c_notify_user_canister_events.rs b/backend/canisters/user/impl/src/updates/c2c_notify_user_canister_events.rs index 46a9f370e1..9d75faa446 100644 --- a/backend/canisters/user/impl/src/updates/c2c_notify_user_canister_events.rs +++ b/backend/canisters/user/impl/src/updates/c2c_notify_user_canister_events.rs @@ -45,10 +45,14 @@ fn c2c_notify_user_canister_events_impl(args: Args, caller_user_id: UserId, stat } fn process_event(event: UserCanisterEvent, caller_user_id: UserId, state: &mut RuntimeState) { + let now = state.env.now(); + match event { UserCanisterEvent::SendMessages(args) => { send_messages(*args, caller_user_id, state); - state.insert_achievement(Achievement::ReceivedDirectMessage); + state + .data + .award_achievement_and_notify(Achievement::ReceivedDirectMessage, now); } UserCanisterEvent::EditMessage(args) => { edit_message(*args, caller_user_id, state); @@ -67,13 +71,12 @@ fn process_event(event: UserCanisterEvent, caller_user_id: UserId, state: &mut R } UserCanisterEvent::MarkMessagesRead(args) => { if let Some(chat) = state.data.direct_chats.get_mut(&caller_user_id.into()) { - let now = state.env.now(); chat.mark_read_up_to(args.read_up_to, false, now); } } UserCanisterEvent::P2PSwapStatusChange(c) => { if let Some(chat) = state.data.direct_chats.get_mut(&caller_user_id.into()) { - chat.events.set_p2p_swap_status(None, c.message_id, c.status, state.env.now()); + chat.events.set_p2p_swap_status(None, c.message_id, c.status, now); } } UserCanisterEvent::JoinVideoCall(c) => { @@ -83,7 +86,7 @@ fn process_event(event: UserCanisterEvent, caller_user_id: UserId, state: &mut R c.message_id, VideoCallPresence::Default, EventIndex::default(), - state.env.now(), + now, ); } } diff --git a/backend/canisters/user/impl/src/updates/claim_daily_chit.rs b/backend/canisters/user/impl/src/updates/claim_daily_chit.rs new file mode 100644 index 0000000000..e56ed09b4b --- /dev/null +++ b/backend/canisters/user/impl/src/updates/claim_daily_chit.rs @@ -0,0 +1,94 @@ +use crate::guards::caller_is_owner; +use crate::{mutate_state, RuntimeState}; +use canister_tracing_macros::trace; +use event_store_producer::EventBuilder; +use ic_cdk::update; +use serde::Serialize; +use types::{Achievement, ChitEarned, ChitEarnedReason, Timestamped, UserId}; +use user_index_canister::claim_daily_chit::{Response::*, *}; +use utils::time::tomorrow; + +#[update(guard = "caller_is_owner")] +#[trace] +fn claim_daily_chit(_args: Args) -> Response { + mutate_state(claim_daily_chit_impl) +} + +fn claim_daily_chit_impl(state: &mut RuntimeState) -> Response { + let now = state.env.now(); + let tomorrow = tomorrow(now); + + if !state.data.streak.claim(now) { + return AlreadyClaimed(tomorrow); + } + + let user_id: UserId = state.env.canister_id().into(); + let streak = state.data.streak.days(now); + let chit_earned = chit_for_streak(streak); + + state.data.chit_balance = Timestamped::new(state.data.chit_balance.value + chit_earned as i32, now); + + state.data.chit_events.push(ChitEarned { + amount: chit_earned as i32, + timestamp: now, + reason: ChitEarnedReason::DailyClaim, + }); + + if streak >= 3 { + state.data.award_achievement(Achievement::Streak3, now); + } + + if streak >= 7 { + state.data.award_achievement(Achievement::Streak7, now); + } + + if streak >= 14 { + state.data.award_achievement(Achievement::Streak14, now); + } + + if streak >= 30 { + state.data.award_achievement(Achievement::Streak30, now); + } + + state.data.notify_user_index_of_chit(now); + + state.data.event_store_client.push( + EventBuilder::new("user_claimed_daily_chit", now) + .with_user(user_id.to_string(), true) + .with_source(user_id.to_string(), true) + .with_json_payload(&UserClaimedDailyChitEventPayload { streak, chit_earned }) + .build(), + ); + + Success(SuccessResult { + chit_earned, + chit_balance: state.data.chit_balance.value, + streak, + next_claim: tomorrow, + }) +} + +fn chit_for_streak(days: u16) -> u32 { + if days == 0 { + return 0; + } + if days < 3 { + return 200; + } + if days < 7 { + return 300; + } + if days < 14 { + return 400; + } + if days < 30 { + return 500; + } + 600 +} + +#[derive(Serialize)] +struct UserClaimedDailyChitEventPayload { + streak: u16, + chit_earned: u32, +} diff --git a/backend/canisters/user/impl/src/updates/mod.rs b/backend/canisters/user/impl/src/updates/mod.rs index a9d1f50d36..8fb798d8c4 100644 --- a/backend/canisters/user/impl/src/updates/mod.rs +++ b/backend/canisters/user/impl/src/updates/mod.rs @@ -22,6 +22,7 @@ pub mod c2c_set_user_suspended; pub mod c2c_vote_on_proposal; pub mod cancel_message_reminder; pub mod cancel_p2p_swap; +pub mod claim_daily_chit; pub mod create_community; pub mod create_group; pub mod delete_community; diff --git a/backend/canisters/user/impl/src/updates/send_message.rs b/backend/canisters/user/impl/src/updates/send_message.rs index 7e8073d287..bcb46f0f09 100644 --- a/backend/canisters/user/impl/src/updates/send_message.rs +++ b/backend/canisters/user/impl/src/updates/send_message.rs @@ -289,7 +289,7 @@ fn send_message_impl( ); } - state.insert_achievement(Achievement::SentDirectMessage); + state.data.award_achievement_and_notify(Achievement::SentDirectMessage, now); } register_timer_jobs( diff --git a/backend/canisters/user/impl/src/updates/set_avatar.rs b/backend/canisters/user/impl/src/updates/set_avatar.rs index 600dc6e141..c03fa356ae 100644 --- a/backend/canisters/user/impl/src/updates/set_avatar.rs +++ b/backend/canisters/user/impl/src/updates/set_avatar.rs @@ -29,7 +29,7 @@ fn set_avatar_impl(args: Args, state: &mut RuntimeState) -> Response { state.data.avatar = Timestamped::new(args.avatar, now); - state.insert_achievement(Achievement::SetAvatar); + state.data.award_achievement_and_notify(Achievement::SetAvatar, now); ic_cdk::spawn(update_index_canister(state.data.user_index_canister_id, id)); diff --git a/backend/canisters/user/impl/src/updates/set_bio.rs b/backend/canisters/user/impl/src/updates/set_bio.rs index 27198868b0..66840f386b 100644 --- a/backend/canisters/user/impl/src/updates/set_bio.rs +++ b/backend/canisters/user/impl/src/updates/set_bio.rs @@ -31,7 +31,7 @@ fn set_bio_impl(args: Args, state: &mut RuntimeState) -> Response { let now = state.env.now(); state.data.bio = Timestamped::new(args.text, now); - state.insert_achievement(Achievement::SetBio); + state.data.award_achievement_and_notify(Achievement::SetBio, now); Success } diff --git a/backend/canisters/user_index/CHANGELOG.md b/backend/canisters/user_index/CHANGELOG.md index 916c11892e..6fdb6e7117 100644 --- a/backend/canisters/user_index/CHANGELOG.md +++ b/backend/canisters/user_index/CHANGELOG.md @@ -6,11 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +### Added + +- Added `c2c_notify_chit` to sync chit balance and streak from user canister ([#5972](https://github.com/open-chat-labs/open-chat/pull/5972)) + ### Changed -- Get volatile data for users created since timestamp ([#5921](https://github.com/open-chat-labs/open-chat/pull/5921)) - In `ChitEarnedReason::Achievement` replaced `String` with `Achievement` ([#5962](https://github.com/open-chat-labs/open-chat/pull/5962)) +## [[2.0.1200](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1200-user_index)] - 2024-06-07 + +### Changed + +- Get volatile data for users created since timestamp ([#5921](https://github.com/open-chat-labs/open-chat/pull/5921)) + ## [[2.0.1199](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1199-user_index)] - 2024-06-07 ### Changed diff --git a/backend/canisters/user_index/api/src/updates/c2c_notify_chit.rs b/backend/canisters/user_index/api/src/updates/c2c_notify_chit.rs new file mode 100644 index 0000000000..9c6d81c0b9 --- /dev/null +++ b/backend/canisters/user_index/api/src/updates/c2c_notify_chit.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use types::TimestampMillis; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Args { + pub timestamp: TimestampMillis, + pub chit_balance: i32, + pub streak: u16, + pub streak_ends: TimestampMillis, +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum Response { + Success, + UserNotFound, +} diff --git a/backend/canisters/user_index/api/src/updates/mod.rs b/backend/canisters/user_index/api/src/updates/mod.rs index 11f515c0cb..6d81ad589c 100644 --- a/backend/canisters/user_index/api/src/updates/mod.rs +++ b/backend/canisters/user_index/api/src/updates/mod.rs @@ -4,6 +4,7 @@ pub mod add_platform_operator; pub mod add_referral_codes; pub mod assign_platform_moderators_group; pub mod c2c_mark_user_canister_empty; +pub mod c2c_notify_chit; pub mod c2c_notify_events; pub mod c2c_notify_low_balance; pub mod c2c_register_bot; diff --git a/backend/canisters/user_index/c2c_client/src/lib.rs b/backend/canisters/user_index/c2c_client/src/lib.rs index e8ca36ca2d..ce8798ec7c 100644 --- a/backend/canisters/user_index/c2c_client/src/lib.rs +++ b/backend/canisters/user_index/c2c_client/src/lib.rs @@ -11,6 +11,7 @@ generate_c2c_call!(user); // Updates generate_c2c_call!(c2c_mark_user_canister_empty); generate_c2c_call!(c2c_report_message); +generate_c2c_call!(c2c_notify_chit); generate_c2c_call!(c2c_notify_events); generate_candid_c2c_call_with_payment!(c2c_register_bot); generate_c2c_call!(c2c_send_openchat_bot_messages); diff --git a/backend/canisters/user_index/impl/src/lib.rs b/backend/canisters/user_index/impl/src/lib.rs index 38707f9378..76c06cd452 100644 --- a/backend/canisters/user_index/impl/src/lib.rs +++ b/backend/canisters/user_index/impl/src/lib.rs @@ -16,6 +16,7 @@ use model::local_user_index_map::LocalUserIndexMap; use model::pending_modclub_submissions_queue::{PendingModclubSubmission, PendingModclubSubmissionsQueue}; use model::pending_payments_queue::{PendingPayment, PendingPaymentsQueue}; use model::reported_messages::{ReportedMessages, ReportingMetrics}; +use model::user::SuspensionDetails; use nns_governance_canister::types::manage_neuron::claim_or_refresh::By; use nns_governance_canister::types::manage_neuron::{ClaimOrRefresh, Command}; use nns_governance_canister::types::{Empty, ManageNeuron, NeuronId}; @@ -157,8 +158,31 @@ impl RuntimeState { jobs::submit_message_to_modclub::start_job_if_required(self); } + pub fn user_metrics(&self, user_id: UserId) -> Option { + self.data.users.get_by_user_id(&user_id).map(|user| { + let now = self.env.now(); + UserMetrics { + now, + username: user.username.clone(), + date_created: user.date_created, + date_updated: user.date_updated, + is_bot: user.is_bot, + suspension_details: user.suspension_details.clone(), + moderation_flags_enabled: user.moderation_flags_enabled, + chit_balance: user.chit_balance, + chit_balance_v2: user.chit_balance_v2, + streak: user.streak.days(now), + streak_v2: user.streak_v2, + streak_ends: user.streak_ends, + date_updated_volatile: user.date_updated_volatile, + date_updated_volatile_v2: user.chit_updated, + } + }) + } + pub fn metrics(&self) -> Metrics { let now = self.env.now(); + let canister_upgrades_metrics = self.data.canisters_requiring_upgrade.metrics(); let event_store_client_info = self.data.event_store_client.info(); let event_relay_canister_id = event_store_client_info.event_store_canister_id; @@ -166,7 +190,7 @@ impl RuntimeState { Metrics { heap_memory_used: utils::memory::heap(), stable_memory_used: utils::memory::stable(), - now: self.env.now(), + now, cycles_balance: self.env.cycles_balance(), wasm_version: WASM_VERSION.with_borrow(|v| **v), git_commit_id: utils::git::git_commit_id().to_string(), @@ -451,6 +475,24 @@ pub struct Metrics { pub empty_users_length: usize, } +#[derive(Serialize, Debug)] +pub struct UserMetrics { + pub now: TimestampMillis, + pub username: String, + pub date_created: TimestampMillis, + pub date_updated: TimestampMillis, + pub is_bot: bool, + pub suspension_details: Option, + pub moderation_flags_enabled: u32, + pub chit_balance: i32, + pub chit_balance_v2: i32, + pub streak: u16, + pub streak_v2: u16, + pub streak_ends: TimestampMillis, + pub date_updated_volatile: TimestampMillis, + pub date_updated_volatile_v2: TimestampMillis, +} + #[derive(Serialize, Debug, Default)] pub struct DiamondMembershipMetrics { pub users: DiamondMembershipUserMetrics, diff --git a/backend/canisters/user_index/impl/src/model/user.rs b/backend/canisters/user_index/impl/src/model/user.rs index e9bcc6abe4..bc00d72c6d 100644 --- a/backend/canisters/user_index/impl/src/model/user.rs +++ b/backend/canisters/user_index/impl/src/model/user.rs @@ -68,10 +68,20 @@ pub struct User { pub reported_messages: Vec, #[serde(rename = "cb", alias = "chit_balance", default, skip_serializing_if = "is_default")] pub chit_balance: i32, + #[serde(rename = "c2", alias = "chit_balance_v2", default, skip_serializing_if = "is_default")] + pub chit_balance_v2: i32, #[serde(rename = "st", alias = "streak", default, skip_serializing_if = "is_default")] pub streak: Streak, + #[serde(rename = "s2", alias = "streak_v2", default, skip_serializing_if = "is_default")] + pub streak_v2: u16, + #[serde(rename = "s2", alias = "streak_ends", default, skip_serializing_if = "is_default")] + pub streak_ends: TimestampMillis, #[serde(rename = "dv", alias = "date_updated_volatile", default)] pub date_updated_volatile: TimestampMillis, + #[serde(rename = "d2", alias = "chit_updated", alias = "date_updated_volatile_v2", default)] + pub chit_updated: TimestampMillis, + #[serde(rename = "lc", alias = "lastest_chit_event", default)] + pub lastest_chit_event: TimestampMillis, } impl User { @@ -163,6 +173,7 @@ impl User { date_created: now, date_updated: now, date_updated_volatile: now, + chit_updated: now, cycle_top_ups: Vec::new(), avatar_id: None, registration_fee: None, @@ -175,7 +186,11 @@ impl User { moderation_flags_enabled: 0, reported_messages: Vec::new(), chit_balance: 0, + chit_balance_v2: 0, streak: Streak::default(), + streak_v2: 0, + streak_ends: 0, + lastest_chit_event: 0, } } @@ -276,7 +291,12 @@ impl Default for User { moderation_flags_enabled: 0, reported_messages: Vec::new(), chit_balance: 0, + chit_balance_v2: 0, streak: Streak::default(), + streak_v2: 0, + streak_ends: 0, + chit_updated: 0, + lastest_chit_event: 0, } } } diff --git a/backend/canisters/user_index/impl/src/model/user_map.rs b/backend/canisters/user_index/impl/src/model/user_map.rs index d61c15be01..f4d89cc2ab 100644 --- a/backend/canisters/user_index/impl/src/model/user_map.rs +++ b/backend/canisters/user_index/impl/src/model/user_map.rs @@ -206,6 +206,32 @@ impl UserMap { } } + pub fn set_chit( + &mut self, + user_id: &UserId, + chit_event_timestamp: TimestampMillis, + chit_balance: i32, + streak: u16, + streak_ends: TimestampMillis, + now: TimestampMillis, + ) -> bool { + let Some(user) = self.users.get_mut(user_id) else { + return false; + }; + + if chit_event_timestamp <= user.lastest_chit_event { + return false; + } + + user.lastest_chit_event = chit_event_timestamp; + user.chit_balance_v2 = chit_balance; + user.streak_v2 = streak; + user.streak_ends = streak_ends; + user.chit_updated = now; + + true + } + #[allow(dead_code)] pub fn give_chit_reward(&mut self, user_id: &UserId, amount: i32, now: TimestampMillis) { if let Some(user) = self.users.get_mut(user_id) { diff --git a/backend/canisters/user_index/impl/src/queries/http_request.rs b/backend/canisters/user_index/impl/src/queries/http_request.rs index 0214df4141..ab0468b09f 100644 --- a/backend/canisters/user_index/impl/src/queries/http_request.rs +++ b/backend/canisters/user_index/impl/src/queries/http_request.rs @@ -1,8 +1,9 @@ use crate::{read_state, RuntimeState}; +use candid::Principal; use http_request::{build_json_response, encode_logs, extract_route, Route}; use ic_cdk::query; use std::collections::BTreeMap; -use types::{HttpRequest, HttpResponse, TimestampMillis}; +use types::{HttpRequest, HttpResponse, TimestampMillis, UserId}; #[query] fn http_request(request: HttpRequest) -> HttpResponse { @@ -40,12 +41,31 @@ fn http_request(request: HttpRequest) -> HttpResponse { build_json_response(&grouped) } + fn handle_other_path(path: String, state: &RuntimeState) -> HttpResponse { + let parts: Vec<_> = path.split('/').collect(); + + match parts[0] { + "usermetrics" => { + let user_id: Option = parts.get(1).and_then(|p| Principal::from_text(*p).ok()).map(|p| p.into()); + if let Some(user_id) = user_id { + if let Some(metrics) = state.user_metrics(user_id) { + return build_json_response(&metrics); + } + } + } + "bots" => return get_bot_users(state), + "new_users_per_day" => return get_new_users_per_day(state), + _ => (), + } + + HttpResponse::not_found() + } + match extract_route(&request.url) { Route::Logs(since) => get_logs_impl(since), Route::Traces(since) => get_traces_impl(since), Route::Metrics => read_state(get_metrics_impl), - Route::Other(path, _) if path == "bots" => read_state(get_bot_users), - Route::Other(path, _) if path == "new_users_per_day" => read_state(get_new_users_per_day), + Route::Other(path, _) => read_state(|state| handle_other_path(path, state)), _ => HttpResponse::not_found(), } } diff --git a/backend/canisters/user_index/impl/src/updates/c2c_notify_chit.rs b/backend/canisters/user_index/impl/src/updates/c2c_notify_chit.rs new file mode 100644 index 0000000000..91211928c7 --- /dev/null +++ b/backend/canisters/user_index/impl/src/updates/c2c_notify_chit.rs @@ -0,0 +1,29 @@ +use crate::{mutate_state, RuntimeState}; +use canister_api_macros::update_msgpack; +use canister_tracing_macros::trace; +use types::UserId; +use user_index_canister::c2c_notify_chit::{Response::*, *}; + +#[update_msgpack] +#[trace] +fn c2c_notify_chit(args: Args) -> Response { + mutate_state(|state| c2c_notify_chit_impl(args, state)) +} + +fn c2c_notify_chit_impl(args: Args, state: &mut RuntimeState) -> Response { + let now = state.env.now(); + let user_id: UserId = state.env.caller().into(); + + if state.data.users.set_chit( + &user_id, + args.timestamp, + args.chit_balance, + args.streak, + args.streak_ends, + now, + ) { + Success + } else { + UserNotFound + } +} diff --git a/backend/canisters/user_index/impl/src/updates/mod.rs b/backend/canisters/user_index/impl/src/updates/mod.rs index 95cfc8a2bd..cc6e458ac6 100644 --- a/backend/canisters/user_index/impl/src/updates/mod.rs +++ b/backend/canisters/user_index/impl/src/updates/mod.rs @@ -4,6 +4,7 @@ pub mod add_platform_operator; pub mod add_referral_codes; pub mod assign_platform_moderators_group; pub mod c2c_mark_user_canister_empty; +pub mod c2c_notify_chit; pub mod c2c_notify_events; pub mod c2c_notify_low_balance; pub mod c2c_register_bot; diff --git a/backend/libraries/utils/src/streak.rs b/backend/libraries/utils/src/streak.rs index 67dcb07eca..8f8bef1e97 100644 --- a/backend/libraries/utils/src/streak.rs +++ b/backend/libraries/utils/src/streak.rs @@ -21,6 +21,10 @@ impl Streak { 0 } + pub fn ends(&self) -> TimestampMillis { + Streak::day_to_timestamp(self.end_day + 1) + } + pub fn claim(&mut self, now: TimestampMillis) -> bool { if let Some(today) = Streak::timestamp_to_day(now) { if today > self.end_day { @@ -66,6 +70,10 @@ impl Streak { fn is_new_streak(&self, today: u16) -> bool { today > (self.end_day + 1) } + + fn day_to_timestamp(day: u16) -> TimestampMillis { + DAY_ZERO + MS_IN_DAY * day as u64 + } } #[cfg(test)]