Skip to content

Commit 424ee84

Browse files
committed
feat: implement realtime scrims chat
1 parent 74e74ab commit 424ee84

13 files changed

Lines changed: 530 additions & 11 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
# Sends a scrim chat message notification to the ProStaff Discord bot webhook.
4+
#
5+
# Runs in the background so Action Cable broadcasts are never delayed by
6+
# outbound HTTP. Failures are retried up to 3 times with exponential backoff.
7+
class DiscordScrimMessageJob < ApplicationJob
8+
queue_as :default
9+
10+
# Only retry on network-layer failures, not programming errors.
11+
retry_on Faraday::Error, wait: :polynomially_longer, attempts: 3
12+
13+
ALLOWED_SCHEMES = %w[http https].freeze
14+
BLOCKED_HOSTS = %w[169.254.169.254 metadata.google.internal].freeze
15+
16+
def perform(message_id)
17+
message = ScrimMessage.includes(:scrim, :user, :organization).find_by(id: message_id)
18+
return unless message
19+
20+
url = DiscordWebhookService::WEBHOOK_URL
21+
secret = DiscordWebhookService::WEBHOOK_SECRET
22+
guild = DiscordWebhookService::GUILD_ID
23+
24+
return unless url.present? && guild.present?
25+
26+
validated_url = validate_webhook_url!(url)
27+
payload = build_payload(message, guild, secret)
28+
post_to_bot(validated_url, payload)
29+
end
30+
31+
private
32+
33+
# Validates that the webhook URL is an http/https URL and not a known internal
34+
# cloud metadata address, protecting against SSRF from a misconfigured env var.
35+
def validate_webhook_url!(url)
36+
parsed = URI.parse(url)
37+
38+
unless ALLOWED_SCHEMES.include?(parsed.scheme)
39+
raise ArgumentError, "[DiscordScrimMessageJob] Invalid webhook URL scheme: #{parsed.scheme}"
40+
end
41+
42+
if BLOCKED_HOSTS.include?(parsed.host)
43+
raise ArgumentError, "[DiscordScrimMessageJob] Blocked webhook host: #{parsed.host}"
44+
end
45+
46+
url
47+
rescue URI::InvalidURIError => e
48+
raise ArgumentError, "[DiscordScrimMessageJob] Malformed webhook URL: #{e.message}"
49+
end
50+
51+
def build_payload(message, guild_id, secret)
52+
scrim = message.scrim
53+
opponent = scrim.opponent_team&.name || 'Opponent'
54+
55+
payload = {
56+
guild_id: guild_id,
57+
scrim_id: scrim.id.to_s,
58+
scrim_opponent: opponent,
59+
message: {
60+
content: message.content,
61+
user: { full_name: message.user.full_name },
62+
organization: { name: message.organization.name }
63+
}
64+
}
65+
payload[:secret] = secret if secret.present?
66+
payload
67+
end
68+
69+
def post_to_bot(url, payload)
70+
conn = Faraday.new(url: url) do |f|
71+
f.request :json
72+
f.response :raise_error
73+
f.adapter Faraday.default_adapter
74+
end
75+
conn.post('/webhooks/scrim-message', payload)
76+
end
77+
end

app/models/scrim_message.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
# Model representing a chat message sent within a scrim session.
4+
#
5+
# ScrimMessage supports cross-organization communication between the two teams
6+
# participating in a scrim. Each message is scoped to a specific scrim and
7+
# retains the sender's organization for display purposes.
8+
#
9+
# Soft-deletion is used so that the conversation history remains consistent
10+
# for other participants after a message is removed.
11+
#
12+
# @example Create a message
13+
# ScrimMessage.create!(
14+
# scrim: scrim,
15+
# user: current_user,
16+
# organization: current_user.organization,
17+
# content: 'gg wp'
18+
# )
19+
class ScrimMessage < ApplicationRecord
20+
MAX_CONTENT_LENGTH = 1000
21+
22+
# Associations
23+
belongs_to :scrim
24+
belongs_to :user
25+
belongs_to :organization
26+
27+
# Validations
28+
validates :content, presence: true, length: { maximum: MAX_CONTENT_LENGTH }
29+
30+
# Scopes
31+
scope :active, -> { where(deleted: false) }
32+
scope :chronological, -> { order(created_at: :asc) }
33+
34+
# Callbacks
35+
after_create_commit :broadcast_to_scrim
36+
37+
# Marks the message as deleted without removing the record from the database.
38+
#
39+
# @return [void]
40+
def soft_delete!
41+
update!(deleted: true, deleted_at: Time.current)
42+
end
43+
44+
private
45+
46+
def broadcast_to_scrim
47+
broadcast_via_action_cable
48+
notify_discord
49+
end
50+
51+
def broadcast_via_action_cable
52+
stream = if scrim.scrim_request_id.present?
53+
"scrim_request_chat_#{scrim.scrim_request_id}"
54+
else
55+
"scrim_chat_#{scrim_id}"
56+
end
57+
ActionCable.server.broadcast(stream, cable_payload)
58+
rescue StandardError => e
59+
Rails.logger.error "[ScrimMessage] Action Cable broadcast failed for scrim=#{scrim_id}: #{e.message}"
60+
end
61+
62+
def notify_discord
63+
DiscordWebhookService.notify_new_message(self)
64+
rescue StandardError => e
65+
Rails.logger.warn "[ScrimMessage] Discord notification failed for scrim=#{scrim_id}: #{e.message}"
66+
end
67+
68+
def cable_payload
69+
{
70+
type: 'new_message',
71+
message: {
72+
id: id,
73+
content: content,
74+
created_at: created_at.iso8601,
75+
user: { id: user.id, full_name: user.full_name },
76+
organization: { id: organization_id, name: organization.name }
77+
}
78+
}
79+
end
80+
end
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# frozen_string_literal: true
2+
3+
# ScrimChatChannel — Real-time cross-organization chat for scrim sessions.
4+
#
5+
# Allows members of both participating organizations to exchange messages
6+
# during a scrim. Authorization is based on the scrim's owner org and its
7+
# linked ScrimRequest, which carries both organization IDs.
8+
#
9+
# Subscription params:
10+
# scrim_id [String] — UUID of the scrim to subscribe to
11+
#
12+
# Actions:
13+
# subscribed — validates access, opens the scrim-scoped stream
14+
# speak — persists a message; broadcast is handled by the model callback
15+
# unsubscribed — stops all streams (cleanup)
16+
#
17+
# @example Frontend subscription
18+
# consumer.subscriptions.create(
19+
# { channel: 'ScrimChatChannel', scrim_id: 'uuid' },
20+
# { received: (data) => console.log(data) }
21+
# )
22+
class ScrimChatChannel < ApplicationCable::Channel
23+
MAX_CONTENT_LENGTH = 1000
24+
25+
def subscribed
26+
scrim = find_authorized_scrim
27+
unless scrim
28+
logger.warn "[ScrimChat] Rejected subscription — user=#{current_user.id} scrim_id=#{params[:scrim_id]}"
29+
reject
30+
return
31+
end
32+
33+
@scrim = scrim
34+
stream_name = canonical_stream_name(@scrim)
35+
stream_from stream_name
36+
logger.info "[ScrimChat] subscribed user=#{current_user.id} scrim=#{@scrim.id} stream=#{stream_name}"
37+
end
38+
39+
def unsubscribed
40+
stop_all_streams
41+
logger.info "[ScrimChat] user=#{current_user.id} unsubscribed"
42+
end
43+
44+
# Receives a message from the client and persists it.
45+
# Broadcasting is triggered by ScrimMessage's after_create_commit callback.
46+
#
47+
# @param data [Hash] { "content" => "message text" }
48+
def speak(data)
49+
return unless @scrim
50+
51+
content = validate_content(data['content'])
52+
return unless content
53+
54+
ScrimMessage.create!(scrim: @scrim, user: current_user,
55+
organization: current_user.organization, content: content)
56+
rescue ActiveRecord::RecordInvalid => e
57+
logger.error "[ScrimChat] Failed to persist message for scrim=#{@scrim.id}: #{e.message}"
58+
transmit({ error: 'Failed to send message' })
59+
end
60+
61+
private
62+
63+
def validate_content(raw)
64+
content = raw.to_s.strip
65+
if content.blank?
66+
transmit({ error: 'Message content cannot be blank' })
67+
return nil
68+
end
69+
if content.length > MAX_CONTENT_LENGTH
70+
transmit({ error: "Message exceeds #{MAX_CONTENT_LENGTH} characters" })
71+
return nil
72+
end
73+
content
74+
end
75+
76+
# Finds the scrim and verifies the current user's org is a participant.
77+
#
78+
# Checks owner org first, then falls back to ScrimRequest cross-org check.
79+
# Always returns nil for both "not found" and "not a participant" cases so
80+
# that foreign scrim UUIDs are not leaked via subscription rejection messages.
81+
#
82+
# @return [Scrim, nil]
83+
def find_authorized_scrim
84+
scrim_id = params[:scrim_id]
85+
return nil unless scrim_id.present?
86+
87+
# ActionCable context doesn't go through authenticate_request!, so
88+
# Current.organization_id must be set manually for OrganizationScoped models.
89+
Current.organization_id = current_user.organization_id
90+
91+
# Owner org — most common path
92+
scrim = current_user.organization.scrims.find_by(id: scrim_id)
93+
return scrim if scrim
94+
95+
# Cross-org participant via ScrimRequest
96+
cross_org_scrim(scrim_id)
97+
end
98+
99+
# Returns the scrim if the current user's org is the opposing participant
100+
# in the linked ScrimRequest. Returns nil otherwise.
101+
def cross_org_scrim(scrim_id)
102+
# Bypass OrganizationScoped — the scrim may belong to the opponent's org
103+
scrim = Scrim.unscoped_by_organization.find_by(id: scrim_id)
104+
return nil unless scrim
105+
106+
request = scrim_request_for(scrim)
107+
return nil unless request
108+
109+
org_id = current_user.organization_id
110+
return scrim if request.requesting_organization_id == org_id ||
111+
request.target_organization_id == org_id
112+
113+
nil
114+
end
115+
116+
def scrim_request_for(scrim)
117+
return nil unless scrim.scrim_request_id.present?
118+
119+
ScrimRequest.find_by(id: scrim.scrim_request_id)
120+
end
121+
122+
# Uses ScrimRequest ID as canonical stream so both orgs share the same channel.
123+
# Falls back to per-scrim stream when no request is linked (manual scrims).
124+
def canonical_stream_name(scrim)
125+
if scrim.scrim_request_id.present?
126+
"scrim_request_chat_#{scrim.scrim_request_id}"
127+
else
128+
"scrim_chat_#{scrim.id}"
129+
end
130+
end
131+
end

0 commit comments

Comments
 (0)