mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
FEATURE: Enable voice badges by default and harden them first (#43190)
The team wants the voice badge set on by default. Reviewing the fixtures and grant hooks first turned up a handful of issues that only bite once real sites accumulate history, so this PR fixes them and flips the default in one go. ### Fixes - **Scheduled badges were auto-revocable.** Core's `Badge.auto_revoke` defaults to `true` and the fixtures never overrode it. Sessions and co-presence rows are purged after `voice_session_retention_days` (400), and a deleted user's co-presence rows are dropped, so the daily backfill would have silently revoked airtime, bonding, loyalty, exploration and hosting badges (and any titles set from them) as data aged out. All 19 query badges now set `auto_revoke = false`, same as core's cumulative badges. - **Crowd Puller / Master of Ceremonies were farmable.** The hosting query counted `COUNT(s.id)` across a creator's rooms, including the creator's own joins. At the 30/min join rate limit a Gold title took ~17 minutes. It now counts `DISTINCT` visitors excluding the creator. - **Packed House was unreachable for most rooms.** It read `room.max_participants`, which is `nil` whenever the room is capped by the site-wide setting. It now uses `effective_max_participants`, matching admission. - **Bulk toggles skipped badge maintenance.** `enable_all!`/`disable_all!` used `update_all`, bypassing the `Badge` callbacks that refresh featured ranks and distinct badge counts, leaving stale profile counts. They now run that once after the flip, and enabling also enqueues a backfill for the query badges so they show up right away instead of after the next daily job. - **Analytics dependency made explicit.** Every badge is computed from `voice_sessions`; with analytics off nothing can be earned. The hooks now check `voice_analytics_enabled` and the setting description references it. ### Default flip `voice_badges_enabled` now defaults to `true` and the fixtures seed badges as `default_enabled`. Neither reaches existing sites on its own: the settings-changed hook doesn't fire for a default change, and `default_enabled` only applies to new records. A post-migration enables the Voice-grouping badges on sites that have no explicit `voice_badges_enabled = false` row. ### Tests - New `badge_backfill_spec.rb` runs `BadgeGranter.backfill` for every SQL family at its threshold boundary, including the retention-purge case and the hosting self-join exclusion. - Migration spec covers untouched, explicitly-disabled, and non-Voice badges. - Hooks spec updated for the site-cap fallback, analytics gate, default-enabled seeding, and backfill enqueueing. ### Not changed (design notes from the review) - Weekend Warrior and Loyalty use server UTC for day boundaries while Night Owl / Early Bird use the user's timezone. Left as is; user timezones in SQL would need per-row `AT TIME ZONE` with invalid-name handling. - Icebreaker largely overlaps Mic Check since co-presence rows lag five minutes. Bronze, harmless. - Marathoner relies on `voice_afk_disconnect_threshold_minutes` (30) to keep idle tabs from earning a Gold title.
This commit is contained in:
@@ -24410,6 +24410,7 @@ SET search_path TO "$user", public;
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20260904065041'),
|
||||
('20260904063128'),
|
||||
('20260903195501'),
|
||||
('20260903065141'),
|
||||
('20260902150024'),
|
||||
('20260901020329'),
|
||||
|
||||
@@ -371,7 +371,7 @@ module Voice
|
||||
end
|
||||
Voice::UserStatusManager.clear_voice_status(current_user)
|
||||
Voice::RoomBroadcaster.publish_participants(@room)
|
||||
Voice::BadgeGranterHooks.on_leave(current_user, session, room: @room)
|
||||
Voice::BadgeGranterHooks.on_leave(current_user, session)
|
||||
head :no_content
|
||||
end
|
||||
|
||||
@@ -505,7 +505,7 @@ module Voice
|
||||
kicked_user = User.find_by(id: user_id)
|
||||
Voice::UserStatusManager.clear_voice_status(kicked_user) if kicked_user
|
||||
|
||||
Voice::BadgeGranterHooks.on_leave(kicked_user, session, room: @room) if kicked_user
|
||||
Voice::BadgeGranterHooks.on_leave(kicked_user, session) if kicked_user
|
||||
Voice::RoomBroadcaster.publish_kick(@room, user_id)
|
||||
Voice::RoomBroadcaster.publish_participants(@room)
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ module Jobs
|
||||
::Voice::ParticipantTracker.remove(session.room_id, session.user_id)
|
||||
|
||||
user = User.find_by(id: session.user_id)
|
||||
room = ::Voice::Room.find_by(id: session.room_id)
|
||||
::Voice::BadgeGranterHooks.on_leave(user, session, room: room) if user && room
|
||||
::Voice::BadgeGranterHooks.on_leave(user, session) if user
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,35 @@ module Voice
|
||||
update!(left_at: at)
|
||||
end
|
||||
|
||||
# Seconds of this session during which someone else was in the room.
|
||||
# Concurrent companions are merged so a crowded hour counts once.
|
||||
def accompanied_seconds
|
||||
finish = left_at || Time.current
|
||||
intervals =
|
||||
self
|
||||
.class
|
||||
.where(room_id: room_id)
|
||||
.where.not(user_id: user_id)
|
||||
.where("joined_at < ?", finish)
|
||||
.where("left_at IS NULL OR left_at > ?", joined_at)
|
||||
.order(:joined_at)
|
||||
.pluck(:joined_at, :left_at)
|
||||
.map { |start, stop| [[start, joined_at].max, [stop || Time.current, finish].min] }
|
||||
|
||||
total = 0
|
||||
merged_start = merged_end = nil
|
||||
intervals.each do |start, stop|
|
||||
if merged_end && start <= merged_end
|
||||
merged_end = [merged_end, stop].max
|
||||
else
|
||||
total += merged_end - merged_start if merged_end
|
||||
merged_start, merged_end = start, stop
|
||||
end
|
||||
end
|
||||
total += merged_end - merged_start if merged_end
|
||||
total.to_i
|
||||
end
|
||||
|
||||
# A session's recorded end can be far later than the user's actual leave —
|
||||
# the orphan sweep stamps left_at when it finally runs, and until then an
|
||||
# abandoned session is open-ended — so any single shared interval is
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
module Voice
|
||||
class BadgeGranterHooks
|
||||
def self.on_leave(user, session, room:)
|
||||
def self.on_leave(user, session)
|
||||
return unless badges_enabled?
|
||||
return if session&.left_at.blank?
|
||||
|
||||
grant("Mic Check", user) if mic_check?(session, room)
|
||||
grant("Mic Check", user) if mic_check?(session)
|
||||
grant("Night Owl", user) if night_owl?(user, session)
|
||||
grant("Early Bird", user) if early_bird?(user, session)
|
||||
grant("Marathoner", user) if marathoner?(session)
|
||||
@@ -33,14 +33,22 @@ module Voice
|
||||
|
||||
BADGE_GROUP_NAME = "Voice"
|
||||
|
||||
# The site setting is a master switch over the whole grouping. Badges are
|
||||
# flipped in bulk, so the per-badge save callbacks that keep user badge
|
||||
# counts consistent run once here instead.
|
||||
def self.enable_all!
|
||||
grouping = BadgeGrouping.find_by(name: BADGE_GROUP_NAME)
|
||||
Badge.where(badge_grouping_id: grouping.id).update_all(enabled: true) if grouping
|
||||
badges = voice_badges
|
||||
badges.update_all(enabled: true)
|
||||
sync_user_badges!
|
||||
badges
|
||||
.where.not(query: nil)
|
||||
.pluck(:id)
|
||||
.each { |badge_id| Jobs.enqueue(:backfill_badge, badge_id: badge_id) }
|
||||
end
|
||||
|
||||
def self.disable_all!
|
||||
grouping = BadgeGrouping.find_by(name: BADGE_GROUP_NAME)
|
||||
Badge.where(badge_grouping_id: grouping.id).update_all(enabled: false) if grouping
|
||||
voice_badges.update_all(enabled: false)
|
||||
sync_user_badges!
|
||||
end
|
||||
|
||||
class << self
|
||||
@@ -51,9 +59,8 @@ module Voice
|
||||
BadgeGranter.grant(badge, user) if badge&.enabled?
|
||||
end
|
||||
|
||||
def mic_check?(session, room)
|
||||
duration = (session.left_at - session.joined_at).to_i
|
||||
duration >= 30 && Voice::ParticipantTracker.user_ids(room.id).any?
|
||||
def mic_check?(session)
|
||||
session.accompanied_seconds >= 30
|
||||
end
|
||||
|
||||
def night_owl?(user, session)
|
||||
@@ -67,12 +74,11 @@ module Voice
|
||||
end
|
||||
|
||||
def marathoner?(session)
|
||||
duration = (session.left_at - session.joined_at).to_i
|
||||
duration >= 4.hours.to_i
|
||||
session.accompanied_seconds >= 4.hours.to_i
|
||||
end
|
||||
|
||||
def room_full?(room, participants)
|
||||
room.max_participants.present? && participants.count >= room.max_participants
|
||||
participants.count >= room.effective_max_participants
|
||||
end
|
||||
|
||||
def icebreaker?(user, participants)
|
||||
@@ -90,8 +96,20 @@ module Voice
|
||||
time.in_time_zone(tz).hour
|
||||
end
|
||||
|
||||
def voice_badges
|
||||
Badge.joins(:badge_grouping).where(badge_groupings: { name: BADGE_GROUP_NAME })
|
||||
end
|
||||
|
||||
def sync_user_badges!
|
||||
UserBadge.ensure_consistency!
|
||||
UserStat.update_distinct_badge_count
|
||||
end
|
||||
|
||||
# Every badge here is derived from analytics sessions, so without them
|
||||
# nothing can be earned.
|
||||
def badges_enabled?
|
||||
SiteSetting.enable_badges && SiteSetting.voice_badges_enabled
|
||||
SiteSetting.enable_badges && SiteSetting.voice_badges_enabled &&
|
||||
SiteSetting.voice_analytics_enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,9 @@ en:
|
||||
voice_max_room_participants: "Maximum number of people who can be in a voice room at the same time. A room's own participant limit can lower this, but never raise it."
|
||||
voice_auto_status_enabled: "Automatically set user status when they join a voice room (e.g. '🎙️ In Watercooler'). Requires the 'enable user status' site setting."
|
||||
voice_mesh_privacy_warning_enabled: "Warn users before they join a peer-to-peer voice room that other participants may be able to see their IP address, with an option to not show the warning again on that device. Calls hosted on a LiveKit server never show the warning."
|
||||
voice_badges_enabled: "Enable voice chat badges. Grants badges for voice room milestones like time spent, rooms visited, and connections made."
|
||||
voice_badges_enabled: "Enable voice chat badges. Grants badges for voice room milestones like time spent, rooms visited, and connections made. Toggling this enables or disables every badge in the Voice group at once. Requires {{setting:voice_analytics_enabled}}, which records the sessions the badges are computed from."
|
||||
voice_analytics_enabled: "Record who joined which voice room and when, and how long people spent in a room together. Powers the admin voice statistics, the contacts and companions suggestions, and every voice badge."
|
||||
voice_session_retention_days: "Days to keep voice session and time-together history before it is deleted. Badges already earned are never taken away, but progress toward badges that accumulate over time (hours in voice, days in a room, people met) only counts history within this window."
|
||||
voice_idle_threshold_minutes: "Minutes of inactivity before a voice room participant is marked as idle. Set to 0 to disable."
|
||||
voice_afk_auto_mute_threshold_minutes: "Minutes of inactivity before a voice room participant is automatically muted. Set to 0 to disable."
|
||||
voice_afk_disconnect_threshold_minutes: "Minutes of inactivity before a voice room participant is automatically disconnected. Set to 0 to disable."
|
||||
@@ -238,5 +240,5 @@ en:
|
||||
long_description: "Awarded for accumulating 5 or more hours of voice chat on weekends."
|
||||
marathoner:
|
||||
name: "Marathoner"
|
||||
description: "Stayed in a single voice session for 4 hours."
|
||||
long_description: "Awarded for staying in a single continuous voice session for 4 or more hours."
|
||||
description: "Spent 4 hours in a single voice session with company."
|
||||
long_description: "Awarded for a single continuous voice session with 4 or more hours spent while at least one other person was in the room."
|
||||
|
||||
@@ -158,11 +158,11 @@ plugins:
|
||||
default: true
|
||||
client: true
|
||||
voice_badges_enabled:
|
||||
default: false
|
||||
default: true
|
||||
voice_analytics_enabled:
|
||||
default: true
|
||||
voice_session_retention_days:
|
||||
default: 400
|
||||
default: 1095
|
||||
min: 7
|
||||
max: 3650
|
||||
voice_chat_enabled:
|
||||
|
||||
@@ -62,7 +62,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -86,9 +86,10 @@ end
|
||||
GROUP BY user_id
|
||||
HAVING SUM(#{duration_sql}) >= #{threshold}
|
||||
SQL
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = type == BadgeType::Gold
|
||||
b.system = true
|
||||
end
|
||||
@@ -106,7 +107,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -122,9 +123,10 @@ end
|
||||
b.target_posts = false
|
||||
b.show_posts = false
|
||||
b.query = co_presence_distinct_partners.call(count)
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = type == BadgeType::Gold
|
||||
b.system = true
|
||||
end
|
||||
@@ -145,9 +147,10 @@ end
|
||||
b.target_posts = false
|
||||
b.show_posts = false
|
||||
b.query = co_presence_with_one_partner.call(threshold)
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = type == BadgeType::Gold
|
||||
b.system = true
|
||||
end
|
||||
@@ -168,14 +171,16 @@ end
|
||||
b.target_posts = false
|
||||
b.show_posts = false
|
||||
b.query = <<~SQL
|
||||
SELECT user_id, current_timestamp granted_at
|
||||
FROM voice_sessions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(DISTINCT room_id) >= #{count}
|
||||
SELECT s.user_id, current_timestamp granted_at
|
||||
FROM voice_sessions s
|
||||
JOIN voice_rooms r ON r.id = s.room_id AND NOT r.ephemeral
|
||||
GROUP BY s.user_id
|
||||
HAVING COUNT(DISTINCT s.room_id) >= #{count}
|
||||
SQL
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = type == BadgeType::Gold
|
||||
b.system = true
|
||||
end
|
||||
@@ -196,9 +201,10 @@ end
|
||||
b.target_posts = false
|
||||
b.show_posts = false
|
||||
b.query = loyalty_query.call(days)
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = type == BadgeType::Gold
|
||||
b.system = true
|
||||
end
|
||||
@@ -216,7 +222,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -234,13 +240,15 @@ end
|
||||
b.query = <<~SQL
|
||||
SELECT r.creator_id user_id, current_timestamp granted_at
|
||||
FROM voice_rooms r
|
||||
JOIN voice_sessions s ON s.room_id = r.id
|
||||
JOIN voice_sessions s ON s.room_id = r.id AND s.user_id <> r.creator_id
|
||||
WHERE NOT r.ephemeral
|
||||
GROUP BY r.creator_id
|
||||
HAVING COUNT(s.id) >= #{count}
|
||||
HAVING COUNT(DISTINCT s.user_id) >= #{count}
|
||||
SQL
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = type == BadgeType::Gold
|
||||
b.system = true
|
||||
end
|
||||
@@ -258,7 +266,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -280,9 +288,10 @@ end
|
||||
GROUP BY invited_by_id
|
||||
HAVING COUNT(DISTINCT user_id) >= #{count}
|
||||
SQL
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = type == BadgeType::Gold
|
||||
b.system = true
|
||||
end
|
||||
@@ -300,7 +309,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -314,7 +323,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -328,7 +337,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -346,9 +355,10 @@ Badge.seed(:name) do |b|
|
||||
GROUP BY user_id
|
||||
HAVING SUM(#{duration_sql}) >= #{5.hours.to_i}
|
||||
SQL
|
||||
b.auto_revoke = false
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -362,7 +372,7 @@ Badge.seed(:name) do |b|
|
||||
b.query = nil
|
||||
b.default_badge_grouping_id = voice_grouping.id
|
||||
b.trigger = Badge::Trigger::None
|
||||
b.default_enabled = false
|
||||
b.default_enabled = true
|
||||
b.default_allow_title = true
|
||||
b.system = true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EnableVoiceBadgesByDefault < ActiveRecord::Migration[8.0]
|
||||
# The setting default flipped to true. Sites that never touched it were
|
||||
# implicitly off and their seeded badges are disabled; the setting-changed
|
||||
# hook that normally flips them never fires for a default change.
|
||||
def up
|
||||
execute <<~SQL
|
||||
UPDATE badges
|
||||
SET enabled = true
|
||||
FROM badge_groupings
|
||||
WHERE badges.badge_grouping_id = badge_groupings.id
|
||||
AND badge_groupings.name = 'Voice'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM site_settings
|
||||
WHERE name = 'voice_badges_enabled' AND value = 'f'
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Jobs::Voice::PurgeOldSessions do
|
||||
fab!(:user)
|
||||
fab!(:partner, :user)
|
||||
fab!(:room, :voice_room)
|
||||
|
||||
before { SiteSetting.voice_enabled = true }
|
||||
|
||||
def create_session(created_at)
|
||||
Fabricate(:voice_session, user: user, room: room).tap do |session|
|
||||
session.update_columns(created_at: created_at)
|
||||
end
|
||||
end
|
||||
|
||||
def create_co_presence(date)
|
||||
first, second = [user.id, partner.id].sort
|
||||
Voice::CoPresence.create!(
|
||||
user_id_1: first,
|
||||
user_id_2: second,
|
||||
date: date,
|
||||
total_seconds: 300,
|
||||
session_count: 1,
|
||||
)
|
||||
end
|
||||
|
||||
it "deletes sessions and co-presence older than the retention period" do
|
||||
retention = SiteSetting.voice_session_retention_days
|
||||
old_session = create_session((retention + 1).days.ago)
|
||||
recent_session = create_session((retention - 1).days.ago)
|
||||
create_co_presence((retention + 1).days.ago.to_date)
|
||||
recent_co_presence = create_co_presence((retention - 1).days.ago.to_date)
|
||||
|
||||
described_class.new.execute({})
|
||||
|
||||
expect(Voice::Session.all).to contain_exactly(recent_session)
|
||||
expect(Voice::CoPresence.all).to contain_exactly(recent_co_presence)
|
||||
expect(Voice::Session.exists?(old_session.id)).to eq(false)
|
||||
end
|
||||
|
||||
it "keeps three years of history by default" do
|
||||
kept = create_session(1094.days.ago)
|
||||
create_session(1096.days.ago)
|
||||
|
||||
described_class.new.execute({})
|
||||
|
||||
expect(Voice::Session.all).to contain_exactly(kept)
|
||||
end
|
||||
|
||||
it "does nothing when voice is disabled" do
|
||||
SiteSetting.voice_enabled = false
|
||||
old_session = create_session(10.years.ago)
|
||||
|
||||
described_class.new.execute({})
|
||||
|
||||
expect(Voice::Session.exists?(old_session.id)).to eq(true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require Rails.root.join(
|
||||
"plugins/voice/db/post_migrate/20260903195501_enable_voice_badges_by_default.rb",
|
||||
)
|
||||
|
||||
RSpec.describe EnableVoiceBadgesByDefault do
|
||||
before do
|
||||
@original_verbose = ActiveRecord::Migration.verbose
|
||||
ActiveRecord::Migration.verbose = false
|
||||
SeedFu.seed(Rails.root.join("plugins/voice/db/fixtures"))
|
||||
Voice::BadgeGranterHooks.disable_all!
|
||||
end
|
||||
|
||||
after { ActiveRecord::Migration.verbose = @original_verbose }
|
||||
|
||||
def voice_badges
|
||||
Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
|
||||
end
|
||||
|
||||
it "enables the voice badges on sites that never set the setting" do
|
||||
described_class.new.up
|
||||
|
||||
expect(voice_badges.where(enabled: false)).to be_empty
|
||||
end
|
||||
|
||||
it "leaves the badges alone on sites that explicitly disabled them" do
|
||||
# The test provider keeps settings in memory; the migration reads the table.
|
||||
DB.exec(
|
||||
"INSERT INTO site_settings (name, data_type, value, created_at, updated_at) VALUES ('voice_badges_enabled', :type, 'f', NOW(), NOW())",
|
||||
type: SiteSettings::TypeSupervisor.types[:bool],
|
||||
)
|
||||
|
||||
described_class.new.up
|
||||
|
||||
expect(voice_badges.where(enabled: true)).to be_empty
|
||||
end
|
||||
|
||||
it "does not touch badges outside the Voice grouping" do
|
||||
Badge.find_by(name: "Editor").update!(enabled: false)
|
||||
|
||||
described_class.new.up
|
||||
|
||||
expect(Badge.find_by(name: "Editor").enabled).to eq(false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Voice::Session do
|
||||
fab!(:user)
|
||||
fab!(:room, :voice_room)
|
||||
|
||||
describe "#accompanied_seconds" do
|
||||
let(:started_at) { Time.zone.parse("2026-09-01 10:00") }
|
||||
let(:session) do
|
||||
Fabricate(
|
||||
:voice_session,
|
||||
user: user,
|
||||
room: room,
|
||||
joined_at: started_at,
|
||||
left_at: started_at + 4.hours,
|
||||
)
|
||||
end
|
||||
|
||||
def companion_session(from, to, in_room: room)
|
||||
Fabricate(
|
||||
:voice_session,
|
||||
user: Fabricate(:user),
|
||||
room: in_room,
|
||||
joined_at: started_at + from,
|
||||
left_at: to && started_at + to,
|
||||
)
|
||||
end
|
||||
|
||||
it "returns zero when nobody else was in the room" do
|
||||
companion_session(0.hours, 4.hours, in_room: Fabricate(:voice_room))
|
||||
|
||||
expect(session.accompanied_seconds).to eq(0)
|
||||
end
|
||||
|
||||
it "clips companions to the session and merges concurrent ones" do
|
||||
companion_session(-1.hour, 1.hour)
|
||||
companion_session(0.5.hours, 1.5.hours)
|
||||
companion_session(3.hours, 6.hours)
|
||||
|
||||
expect(session.accompanied_seconds).to eq(2.5.hours.to_i)
|
||||
end
|
||||
|
||||
it "counts a companion still in the room up to the session end" do
|
||||
freeze_time(started_at + 10.hours)
|
||||
companion_session(2.hours, nil)
|
||||
|
||||
expect(session.accompanied_seconds).to eq(2.hours.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,200 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe BadgeGranter, ".backfill" do
|
||||
fab!(:user)
|
||||
fab!(:room, :voice_room)
|
||||
|
||||
before do
|
||||
SiteSetting.voice_enabled = true
|
||||
SiteSetting.voice_badges_enabled = true
|
||||
SeedFu.seed(Rails.root.join("plugins/voice/db/fixtures"))
|
||||
Voice::BadgeGranterHooks.enable_all!
|
||||
end
|
||||
|
||||
def backfill(badge_name)
|
||||
BadgeGranter.backfill(Badge.find_by!(name: badge_name))
|
||||
end
|
||||
|
||||
def holders_of(badge_name)
|
||||
UserBadge.joins(:badge).where(badges: { name: badge_name }).pluck(:user_id)
|
||||
end
|
||||
|
||||
def create_session(user_id:, room_id: room.id, joined_at:, duration: 10.minutes)
|
||||
Voice::Session.create!(
|
||||
user_id: user_id,
|
||||
room_id: room_id,
|
||||
joined_at: joined_at,
|
||||
left_at: joined_at + duration,
|
||||
)
|
||||
end
|
||||
|
||||
# Session and co-presence rows only reference user ids, so partners need not
|
||||
# be real users; the grantee does, since core joins the users table.
|
||||
def create_co_presence(user_id, partner_id, total_seconds:, date: Date.current)
|
||||
first, second = [user_id, partner_id].sort
|
||||
Voice::CoPresence.create!(
|
||||
user_id_1: first,
|
||||
user_id_2: second,
|
||||
date: date,
|
||||
total_seconds: total_seconds,
|
||||
session_count: 1,
|
||||
)
|
||||
end
|
||||
|
||||
describe "airtime" do
|
||||
it "grants Rookie at one hour of total session time" do
|
||||
create_session(user_id: user.id, joined_at: 3.hours.ago, duration: 30.minutes)
|
||||
create_session(user_id: user.id, joined_at: 2.hours.ago, duration: 29.minutes)
|
||||
backfill("Rookie")
|
||||
expect(holders_of("Rookie")).to be_empty
|
||||
|
||||
create_session(user_id: user.id, joined_at: 1.hour.ago, duration: 1.minute)
|
||||
backfill("Rookie")
|
||||
expect(holders_of("Rookie")).to contain_exactly(user.id)
|
||||
end
|
||||
|
||||
it "counts an open session up to now" do
|
||||
Voice::Session.create!(user: user, room: room, joined_at: 61.minutes.ago, left_at: nil)
|
||||
|
||||
backfill("Rookie")
|
||||
|
||||
expect(holders_of("Rookie")).to contain_exactly(user.id)
|
||||
end
|
||||
|
||||
it "keeps the badge once the sessions that earned it are purged" do
|
||||
create_session(user_id: user.id, joined_at: 2.hours.ago, duration: 1.hour)
|
||||
backfill("Rookie")
|
||||
Voice::Session.delete_all
|
||||
|
||||
backfill("Rookie")
|
||||
|
||||
expect(holders_of("Rookie")).to contain_exactly(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "networker" do
|
||||
it "grants Social Butterfly for ten partners with at least five minutes each" do
|
||||
9.times { |index| create_co_presence(user.id, 100_000 + index, total_seconds: 300) }
|
||||
create_co_presence(user.id, 200_000, total_seconds: 299)
|
||||
backfill("Social Butterfly")
|
||||
expect(holders_of("Social Butterfly")).to be_empty
|
||||
|
||||
create_co_presence(user.id, 200_000, total_seconds: 1, date: Date.yesterday)
|
||||
backfill("Social Butterfly")
|
||||
expect(holders_of("Social Butterfly")).to contain_exactly(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "bonding" do
|
||||
fab!(:partner, :user)
|
||||
|
||||
it "grants Familiar Face to both sides after two hours together across days" do
|
||||
create_co_presence(user.id, partner.id, total_seconds: 1.hour.to_i)
|
||||
create_co_presence(user.id, 100_001, total_seconds: 1.hour.to_i)
|
||||
backfill("Familiar Face")
|
||||
expect(holders_of("Familiar Face")).to be_empty
|
||||
|
||||
create_co_presence(user.id, partner.id, total_seconds: 1.hour.to_i, date: Date.yesterday)
|
||||
backfill("Familiar Face")
|
||||
expect(holders_of("Familiar Face")).to contain_exactly(user.id, partner.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "exploration" do
|
||||
it "grants Explorer for five distinct rooms, ignoring ephemeral ones" do
|
||||
rooms = Array.new(4) { Fabricate(:voice_room) }
|
||||
rooms.each do |visited|
|
||||
2.times { create_session(user_id: user.id, room_id: visited.id, joined_at: 1.day.ago) }
|
||||
end
|
||||
call_room = Fabricate(:voice_ephemeral_room)
|
||||
create_session(user_id: user.id, room_id: call_room.id, joined_at: 1.day.ago)
|
||||
backfill("Explorer")
|
||||
expect(holders_of("Explorer")).to be_empty
|
||||
|
||||
create_session(user_id: user.id, joined_at: 1.hour.ago)
|
||||
backfill("Explorer")
|
||||
expect(holders_of("Explorer")).to contain_exactly(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "loyalty" do
|
||||
it "grants Patron for ten distinct days in the same room" do
|
||||
9.times do |day|
|
||||
create_session(user_id: user.id, joined_at: (day + 1).days.ago.change(hour: 12))
|
||||
create_session(user_id: user.id, joined_at: (day + 1).days.ago.change(hour: 14))
|
||||
end
|
||||
create_session(user_id: user.id, room_id: Fabricate(:voice_room).id, joined_at: 1.hour.ago)
|
||||
backfill("Patron")
|
||||
expect(holders_of("Patron")).to be_empty
|
||||
|
||||
create_session(user_id: user.id, joined_at: 10.days.ago.change(hour: 12))
|
||||
backfill("Patron")
|
||||
expect(holders_of("Patron")).to contain_exactly(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "hosting" do
|
||||
fab!(:hosted_room, :voice_room) { Fabricate(:voice_room, creator: user) }
|
||||
|
||||
it "grants Crowd Puller for fifty distinct visitors, ignoring repeat, self and call joins" do
|
||||
49.times do |index|
|
||||
create_session(user_id: 100_000 + index, room_id: hosted_room.id, joined_at: 1.day.ago)
|
||||
end
|
||||
call_room = Fabricate(:voice_ephemeral_room, creator: user)
|
||||
create_session(user_id: 300_000, room_id: call_room.id, joined_at: 1.day.ago)
|
||||
5.times { create_session(user_id: 100_000, room_id: hosted_room.id, joined_at: 1.hour.ago) }
|
||||
5.times { create_session(user_id: user.id, room_id: hosted_room.id, joined_at: 1.hour.ago) }
|
||||
backfill("Crowd Puller")
|
||||
expect(holders_of("Crowd Puller")).to be_empty
|
||||
|
||||
create_session(user_id: 200_000, room_id: hosted_room.id, joined_at: 1.hour.ago)
|
||||
backfill("Crowd Puller")
|
||||
expect(holders_of("Crowd Puller")).to contain_exactly(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "inviting" do
|
||||
def create_invite(invitee_id, redeemed: true)
|
||||
Voice::Invite.create!(
|
||||
room_id: room.id,
|
||||
user_id: invitee_id,
|
||||
invited_by_id: user.id,
|
||||
redeemed_at: redeemed ? Time.current : nil,
|
||||
)
|
||||
end
|
||||
|
||||
it "grants Connector for ten distinct redeemed invitees" do
|
||||
9.times { |index| create_invite(100_000 + index) }
|
||||
create_invite(200_000, redeemed: false)
|
||||
Voice::Invite.create!(
|
||||
room_id: Fabricate(:voice_room).id,
|
||||
user_id: 100_000,
|
||||
invited_by_id: user.id,
|
||||
redeemed_at: Time.current,
|
||||
)
|
||||
backfill("Connector")
|
||||
expect(holders_of("Connector")).to be_empty
|
||||
|
||||
create_invite(300_000)
|
||||
backfill("Connector")
|
||||
expect(holders_of("Connector")).to contain_exactly(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Weekend Warrior" do
|
||||
it "grants for five hours of weekend sessions only" do
|
||||
saturday = Time.zone.parse("2026-08-29 10:00 UTC")
|
||||
monday = Time.zone.parse("2026-08-31 10:00 UTC")
|
||||
create_session(user_id: user.id, joined_at: monday, duration: 6.hours)
|
||||
create_session(user_id: user.id, joined_at: saturday, duration: 4.hours)
|
||||
backfill("Weekend Warrior")
|
||||
expect(holders_of("Weekend Warrior")).to be_empty
|
||||
|
||||
create_session(user_id: user.id, joined_at: saturday + 1.day, duration: 1.hour)
|
||||
backfill("Weekend Warrior")
|
||||
expect(holders_of("Weekend Warrior")).to contain_exactly(user.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -20,29 +20,29 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
end
|
||||
|
||||
describe "Mic Check" do
|
||||
it "grants when session is 30+ seconds and others are in the room" do
|
||||
other = Fabricate(:user)
|
||||
Voice::ParticipantTracker.add(room.id, other.id)
|
||||
fab!(:other, :user)
|
||||
|
||||
it "grants after 30 seconds spent with someone else in the room" do
|
||||
Fabricate(:voice_session, user: other, room: room, joined_at: 2.minutes.ago)
|
||||
|
||||
session = build_session(joined_at: 1.minute.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).to include("Mic Check")
|
||||
end
|
||||
|
||||
it "does not grant when session is under 30 seconds" do
|
||||
other = Fabricate(:user)
|
||||
Voice::ParticipantTracker.add(room.id, other.id)
|
||||
it "does not grant when the other person was present for under 30 seconds" do
|
||||
Fabricate(:voice_session, user: other, room: room, joined_at: 20.seconds.ago)
|
||||
|
||||
session = build_session(joined_at: 20.seconds.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
session = build_session(joined_at: 1.minute.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).not_to include("Mic Check")
|
||||
end
|
||||
|
||||
it "does not grant when room is empty" do
|
||||
it "does not grant when alone in the room" do
|
||||
session = build_session(joined_at: 1.minute.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).not_to include("Mic Check")
|
||||
end
|
||||
@@ -56,7 +56,7 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
left = joined + 5.minutes
|
||||
|
||||
session = build_session(joined_at: joined, left_at: left)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).to include("Night Owl")
|
||||
end
|
||||
@@ -67,7 +67,7 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
left = joined + 5.minutes
|
||||
|
||||
session = build_session(joined_at: joined, left_at: left)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).not_to include("Night Owl")
|
||||
end
|
||||
@@ -80,23 +80,34 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
left = joined + 5.minutes
|
||||
|
||||
session = build_session(joined_at: joined, left_at: left)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).to include("Early Bird")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Marathoner" do
|
||||
it "grants when session lasted 4+ hours" do
|
||||
fab!(:companion, :user)
|
||||
|
||||
it "grants when 4+ hours of the session were spent with someone else" do
|
||||
Fabricate(:voice_session, user: companion, room: room, joined_at: 4.5.hours.ago)
|
||||
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).to include("Marathoner")
|
||||
end
|
||||
|
||||
it "does not grant for shorter sessions" do
|
||||
session = build_session(joined_at: 3.hours.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
it "does not grant for 4+ hours spent alone" do
|
||||
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).not_to include("Marathoner")
|
||||
end
|
||||
|
||||
it "does not grant when company stayed under 4 hours" do
|
||||
Fabricate(:voice_session, user: companion, room: room, joined_at: 3.hours.ago)
|
||||
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges.pluck(:name)).not_to include("Marathoner")
|
||||
end
|
||||
@@ -106,14 +117,14 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
SiteSetting.voice_badges_enabled = false
|
||||
|
||||
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges).to be_empty
|
||||
end
|
||||
|
||||
it "does nothing when session has no left_at" do
|
||||
session = Voice::Session.create!(user: user, room: room, joined_at: 5.hours.ago)
|
||||
described_class.on_leave(user, session, room: room)
|
||||
described_class.on_leave(user, session)
|
||||
|
||||
expect(user.badges).to be_empty
|
||||
end
|
||||
@@ -131,13 +142,16 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
expect(user.badges.pluck(:name)).to include("Packed House")
|
||||
end
|
||||
|
||||
it "does not grant when room has no max_participants" do
|
||||
it "falls back to the site-wide cap when the room has no max_participants" do
|
||||
room.update!(max_participants: nil)
|
||||
participants = User.where(id: [user.id])
|
||||
|
||||
described_class.on_join(user, room, participants)
|
||||
SiteSetting.voice_max_room_participants = 2
|
||||
other = Fabricate(:user)
|
||||
|
||||
described_class.on_join(user, room, User.where(id: [user.id]))
|
||||
expect(user.badges.pluck(:name)).not_to include("Packed House")
|
||||
|
||||
described_class.on_join(user, room, User.where(id: [user.id, other.id]))
|
||||
expect(user.badges.pluck(:name)).to include("Packed House")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -193,6 +207,14 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
|
||||
expect(user.badges).to be_empty
|
||||
end
|
||||
|
||||
it "does nothing when analytics are disabled" do
|
||||
SiteSetting.voice_analytics_enabled = false
|
||||
|
||||
described_class.on_room_create(user)
|
||||
|
||||
expect(user.badges).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe ".on_invite_redeemed" do
|
||||
@@ -228,11 +250,13 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
|
||||
end
|
||||
|
||||
it "creates all badges as disabled" do
|
||||
described_class.disable_all!
|
||||
|
||||
it "creates all badges enabled" do
|
||||
expect(voice_badges.count).to eq(27)
|
||||
expect(voice_badges.where(enabled: true).count).to eq(0)
|
||||
expect(voice_badges.where(enabled: false).count).to eq(0)
|
||||
end
|
||||
|
||||
it "never auto-revokes scheduled badges" do
|
||||
expect(voice_badges.where.not(query: nil)).to all(have_attributes(auto_revoke: false))
|
||||
end
|
||||
|
||||
it "creates the Voice badge grouping" do
|
||||
@@ -265,11 +289,23 @@ RSpec.describe Voice::BadgeGranterHooks do
|
||||
describe ".enable_all!" do
|
||||
before { described_class.disable_all! }
|
||||
|
||||
it "enables all Voice badges" do
|
||||
it "enables all Voice badges and schedules a backfill for the scheduled ones" do
|
||||
described_class.enable_all!
|
||||
|
||||
voice_badges = Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
|
||||
expect(voice_badges.where(enabled: false).count).to eq(0)
|
||||
expect_job_enqueued(
|
||||
job: :backfill_badge,
|
||||
args: {
|
||||
badge_id: Badge.find_by(name: "Rookie").id,
|
||||
},
|
||||
)
|
||||
expect_not_enqueued_with(
|
||||
job: :backfill_badge,
|
||||
args: {
|
||||
badge_id: Badge.find_by(name: "Mic Check").id,
|
||||
},
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user