feat: Added the ability to create Instagram channel (#11182)
This PR is part of https://github.com/chatwoot/chatwoot/pull/11054 to make the review cycle easier.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Instagram Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/instagram/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/instagram/authorization"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
post "/api/v1/accounts/#{account.id}/instagram/authorization",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'creates a new authorization and returns the redirect url' do
|
||||
post "/api/v1/accounts/#{account.id}/instagram/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['success']).to be true
|
||||
|
||||
instagram_service = Class.new do
|
||||
extend InstagramConcern
|
||||
extend Instagram::IntegrationHelper
|
||||
end
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
response_url = instagram_service.instagram_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{frontend_url}/instagram/callback",
|
||||
scope: Instagram::IntegrationHelper::REQUIRED_SCOPES.join(','),
|
||||
enable_fb_login: '0',
|
||||
force_authentication: '1',
|
||||
response_type: 'code',
|
||||
state: instagram_service.generate_instagram_token(account.id)
|
||||
}
|
||||
)
|
||||
expect(response.parsed_body['url']).to eq response_url
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
138
spec/controllers/concerns/instagram_concern_spec.rb
Normal file
138
spec/controllers/concerns/instagram_concern_spec.rb
Normal file
@@ -0,0 +1,138 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InstagramConcern do
|
||||
let(:dummy_class) { Class.new { include InstagramConcern } }
|
||||
let(:dummy_instance) { dummy_class.new }
|
||||
let(:client_id) { 'test_client_id' }
|
||||
let(:client_secret) { 'test_client_secret' }
|
||||
let(:short_lived_token) { 'short_lived_token' }
|
||||
let(:long_lived_token) { 'long_lived_token' }
|
||||
let(:access_token) { 'access_token' }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_ID', nil).and_return(client_id)
|
||||
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
describe '#instagram_client' do
|
||||
it 'creates an OAuth2 client with correct configuration', :aggregate_failures do
|
||||
client = dummy_instance.instagram_client
|
||||
|
||||
expect(client).to be_a(OAuth2::Client)
|
||||
expect(client.id).to eq(client_id)
|
||||
expect(client.secret).to eq(client_secret)
|
||||
expect(client.site).to eq('https://api.instagram.com')
|
||||
expect(client.options[:authorize_url]).to eq('https://api.instagram.com/oauth/authorize')
|
||||
expect(client.options[:token_url]).to eq('https://api.instagram.com/oauth/access_token')
|
||||
expect(client.options[:auth_scheme]).to eq(:request_body)
|
||||
expect(client.options[:token_method]).to eq(:post)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#exchange_for_long_lived_token' do
|
||||
let(:response_body) { { 'access_token' => long_lived_token, 'expires_in' => 5_184_000 }.to_json }
|
||||
let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) }
|
||||
|
||||
before do
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
allow(mock_response).to receive(:inspect).and_return(response_body)
|
||||
end
|
||||
|
||||
it 'exchanges short lived token for long lived token' do
|
||||
result = dummy_instance.send(:exchange_for_long_lived_token, short_lived_token)
|
||||
|
||||
expect(HTTParty).to have_received(:get).with(
|
||||
'https://graph.instagram.com/access_token',
|
||||
{
|
||||
query: {
|
||||
grant_type: 'ig_exchange_token',
|
||||
client_secret: client_secret,
|
||||
access_token: short_lived_token,
|
||||
client_id: client_id
|
||||
},
|
||||
headers: { 'Accept' => 'application/json' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).to eq({ 'access_token' => long_lived_token, 'expires_in' => 5_184_000 })
|
||||
end
|
||||
|
||||
context 'when the request fails' do
|
||||
let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) }
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
dummy_instance.send(:exchange_for_long_lived_token, short_lived_token)
|
||||
end.to raise_error(RuntimeError, 'Failed to exchange token: Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the response is not valid JSON' do
|
||||
let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) }
|
||||
|
||||
it 'raises a JSON parse error' do
|
||||
allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON'))
|
||||
|
||||
expect { dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) }.to raise_error(JSON::ParserError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_instagram_user_details' do
|
||||
let(:user_details) do
|
||||
{
|
||||
'id' => '12345',
|
||||
'username' => 'test_user',
|
||||
'user_id' => '12345',
|
||||
'name' => 'Test User',
|
||||
'profile_picture_url' => 'https://example.com/profile.jpg',
|
||||
'account_type' => 'BUSINESS'
|
||||
}
|
||||
end
|
||||
let(:response_body) { user_details.to_json }
|
||||
let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) }
|
||||
|
||||
before do
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
allow(mock_response).to receive(:inspect).and_return(response_body)
|
||||
end
|
||||
|
||||
it 'fetches Instagram user details' do
|
||||
result = dummy_instance.send(:fetch_instagram_user_details, access_token)
|
||||
|
||||
expect(HTTParty).to have_received(:get).with(
|
||||
'https://graph.instagram.com/v22.0/me',
|
||||
{
|
||||
query: {
|
||||
fields: 'id,username,user_id,name,profile_picture_url,account_type',
|
||||
access_token: access_token
|
||||
},
|
||||
headers: { 'Accept' => 'application/json' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).to eq(user_details)
|
||||
end
|
||||
|
||||
context 'when the request fails' do
|
||||
let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) }
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
dummy_instance.send(:fetch_instagram_user_details, access_token)
|
||||
end.to raise_error(RuntimeError, 'Failed to fetch Instagram user details: Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the response is not valid JSON' do
|
||||
let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) }
|
||||
|
||||
it 'raises a JSON parse error' do
|
||||
allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON'))
|
||||
|
||||
expect { dummy_instance.send(:fetch_instagram_user_details, access_token) }.to raise_error(JSON::ParserError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
113
spec/controllers/instagram/callbacks_controller_spec.rb
Normal file
113
spec/controllers/instagram/callbacks_controller_spec.rb
Normal file
@@ -0,0 +1,113 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Instagram::CallbacksController do
|
||||
let(:account) { create(:account) }
|
||||
let(:valid_params) { { code: 'valid_code', state: "#{account.id}|valid_token" } }
|
||||
let(:error_params) { { error: 'access_denied', error_description: 'User denied access', state: "#{account.id}|valid_token" } }
|
||||
let(:oauth_client) { instance_double(OAuth2::Client) }
|
||||
let(:auth_code_object) { instance_double(OAuth2::Strategy::AuthCode) }
|
||||
let(:access_token) { instance_double(OAuth2::AccessToken, token: 'test_token') }
|
||||
let(:long_lived_token_response) { { 'access_token' => 'long_lived_test_token', 'expires_in' => 5_184_000 } }
|
||||
let(:user_details) { { 'username' => 'test_user', 'user_id' => '12345' } }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:verify_instagram_token).and_return(account.id)
|
||||
allow(controller).to receive(:instagram_client).and_return(oauth_client)
|
||||
allow(controller).to receive(:base_url).and_return('https://app.chatwoot.com')
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
allow(oauth_client).to receive(:auth_code).and_return(auth_code_object)
|
||||
allow(controller).to receive(:exchange_for_long_lived_token).and_return(long_lived_token_response)
|
||||
allow(controller).to receive(:fetch_instagram_user_details).and_return(user_details)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
allow(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
# Stub the exact request format that's being made
|
||||
stub_request(:post, 'https://graph.instagram.com/v22.0/12345/subscribed_apps?access_token=long_lived_test_token&subscribed_fields%5B%5D=messages&subscribed_fields%5B%5D=message_reactions&subscribed_fields%5B%5D=messaging_seen')
|
||||
.with(
|
||||
headers: {
|
||||
'Accept' => '*/*',
|
||||
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
|
||||
'User-Agent' => 'Ruby'
|
||||
}
|
||||
)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
end
|
||||
|
||||
describe '#show' do
|
||||
context 'when authorization is successful' do
|
||||
before do
|
||||
allow(auth_code_object).to receive(:get_token).and_return(access_token)
|
||||
end
|
||||
|
||||
it 'creates instagram channel and inbox' do
|
||||
expect do
|
||||
get :show, params: valid_params
|
||||
end.to change(Channel::Instagram, :count).by(1).and change(Inbox, :count).by(1)
|
||||
|
||||
expect(Channel::Instagram.last.access_token).to eq('long_lived_test_token')
|
||||
expect(Channel::Instagram.last.instagram_id).to eq('12345')
|
||||
expect(Inbox.last.name).to eq('test_user')
|
||||
|
||||
expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user denies authorization' do
|
||||
it 'redirects to error page with authorization error details' do
|
||||
get :show, params: error_params
|
||||
|
||||
expect(response).to redirect_to(
|
||||
app_new_instagram_inbox_url(
|
||||
account_id: account.id,
|
||||
error_type: 'access_denied',
|
||||
code: 400,
|
||||
error_message: 'User denied access'
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an OAuth error occurs' do
|
||||
before do
|
||||
oauth_error = OAuth2::Error.new(
|
||||
OpenStruct.new(
|
||||
body: { error_type: 'OAuthException', code: 400, error_message: 'Invalid OAuth code' }.to_json,
|
||||
status: 400
|
||||
)
|
||||
)
|
||||
allow(auth_code_object).to receive(:get_token).and_raise(oauth_error)
|
||||
end
|
||||
|
||||
it 'handles OAuth errors and redirects to error page' do
|
||||
get :show, params: valid_params
|
||||
|
||||
expected_url = app_new_instagram_inbox_url(
|
||||
account_id: account.id,
|
||||
error_type: 'OAuthException',
|
||||
code: 400,
|
||||
error_message: 'Invalid OAuth code'
|
||||
)
|
||||
expect(response).to redirect_to(expected_url)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a standard error occurs' do
|
||||
before do
|
||||
allow(auth_code_object).to receive(:get_token).and_raise(StandardError.new('Unknown error'))
|
||||
end
|
||||
|
||||
it 'handles standard errors and redirects to error page' do
|
||||
get :show, params: valid_params
|
||||
|
||||
expected_url = app_new_instagram_inbox_url(
|
||||
account_id: account.id,
|
||||
error_type: 'StandardError',
|
||||
code: 500,
|
||||
error_message: 'Unknown error'
|
||||
)
|
||||
expect(response).to redirect_to(expected_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,21 @@ FactoryBot.define do
|
||||
expires_at { 60.days.from_now }
|
||||
updated_at { 25.hours.ago }
|
||||
|
||||
before :create do |channel|
|
||||
WebMock::API.stub_request(:post, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps")
|
||||
.with(query: {
|
||||
access_token: channel.access_token,
|
||||
subscribed_fields: %w[messages message_reactions messaging_seen]
|
||||
})
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
|
||||
WebMock::API.stub_request(:delete, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps")
|
||||
.with(query: {
|
||||
access_token: channel.access_token
|
||||
})
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
end
|
||||
|
||||
after(:create) do |channel|
|
||||
create(:inbox, channel: channel, account: channel.account)
|
||||
end
|
||||
|
||||
98
spec/helpers/instagram/integration_helper_spec.rb
Normal file
98
spec/helpers/instagram/integration_helper_spec.rb
Normal file
@@ -0,0 +1,98 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Instagram::IntegrationHelper do
|
||||
include described_class
|
||||
|
||||
describe '#generate_instagram_token' do
|
||||
let(:account_id) { 1 }
|
||||
let(:client_secret) { 'test_secret' }
|
||||
let(:current_time) { Time.current }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
|
||||
allow(Time).to receive(:current).and_return(current_time)
|
||||
end
|
||||
|
||||
it 'generates a valid JWT token with correct payload' do
|
||||
token = generate_instagram_token(account_id)
|
||||
decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
|
||||
|
||||
expect(decoded_token['sub']).to eq(account_id)
|
||||
expect(decoded_token['iat']).to eq(current_time.to_i)
|
||||
end
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(generate_instagram_token(account_id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
before do
|
||||
allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
|
||||
end
|
||||
|
||||
it 'logs the error and returns nil' do
|
||||
expect(Rails.logger).to receive(:error).with('Failed to generate Instagram token: Test error')
|
||||
expect(generate_instagram_token(account_id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#token_payload' do
|
||||
let(:account_id) { 1 }
|
||||
let(:current_time) { Time.current }
|
||||
|
||||
before do
|
||||
allow(Time).to receive(:current).and_return(current_time)
|
||||
end
|
||||
|
||||
it 'returns a hash with the correct structure' do
|
||||
payload = token_payload(account_id)
|
||||
|
||||
expect(payload).to be_a(Hash)
|
||||
expect(payload[:sub]).to eq(account_id)
|
||||
expect(payload[:iat]).to eq(current_time.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#verify_instagram_token' do
|
||||
let(:account_id) { 1 }
|
||||
let(:client_secret) { 'test_secret' }
|
||||
let(:valid_token) do
|
||||
JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
|
||||
end
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
|
||||
end
|
||||
|
||||
it 'successfully verifies and returns account_id from valid token' do
|
||||
expect(verify_instagram_token(valid_token)).to eq(account_id)
|
||||
end
|
||||
|
||||
context 'when token is blank' do
|
||||
it 'returns nil' do
|
||||
expect(verify_instagram_token('')).to be_nil
|
||||
expect(verify_instagram_token(nil)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(verify_instagram_token(valid_token)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is invalid' do
|
||||
it 'logs the error and returns nil' do
|
||||
expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Instagram token:/)
|
||||
expect(verify_instagram_token('invalid_token')).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
127
spec/services/instagram/refresh_oauth_token_service_spec.rb
Normal file
127
spec/services/instagram/refresh_oauth_token_service_spec.rb
Normal file
@@ -0,0 +1,127 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Instagram::RefreshOauthTokenService do
|
||||
let(:account) { create(:account) }
|
||||
let(:refresh_response) do
|
||||
{
|
||||
'access_token' => 'new_refreshed_token',
|
||||
'expires_in' => 5_184_000 # 60 days in seconds
|
||||
}
|
||||
end
|
||||
let(:fixed_token) { 'c061d0c51973a8fcab2ecec86f6aa41718414a10070967a5e9a58f49bf8a798e' }
|
||||
let(:instagram_channel) do
|
||||
create(:channel_instagram,
|
||||
account: account,
|
||||
access_token: fixed_token,
|
||||
expires_at: 20.days.from_now) # Set default expiry
|
||||
end
|
||||
let(:service) { described_class.new(channel: instagram_channel) }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://graph.instagram.com/refresh_access_token')
|
||||
.with(
|
||||
query: {
|
||||
'access_token' => fixed_token,
|
||||
'grant_type' => 'ig_refresh_token'
|
||||
},
|
||||
headers: {
|
||||
'Accept' => 'application/json',
|
||||
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
|
||||
'User-Agent' => 'Ruby'
|
||||
}
|
||||
)
|
||||
.to_return(status: 200, body: refresh_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
describe '#access_token' do
|
||||
context 'when token is valid and not eligible for refresh' do
|
||||
before do
|
||||
instagram_channel.update!(
|
||||
updated_at: 12.hours.ago # Less than 24 hours old
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns existing token without refresh' do
|
||||
expect(service).not_to receive(:refresh_long_lived_token)
|
||||
expect(service.access_token).to eq(fixed_token)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is eligible for refresh' do
|
||||
before do
|
||||
instagram_channel.update!(
|
||||
expires_at: 5.days.from_now, # Within 10 days window
|
||||
updated_at: 25.hours.ago # More than 24 hours old
|
||||
)
|
||||
end
|
||||
|
||||
it 'refreshes the token and updates channel' do
|
||||
expect(service.access_token).to eq('new_refreshed_token')
|
||||
instagram_channel.reload
|
||||
expect(instagram_channel.access_token).to eq('new_refreshed_token')
|
||||
expect(instagram_channel.expires_at).to be_within(1.second).of(5_184_000.seconds.from_now)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#token_valid?' do
|
||||
# For the expires_at null test, we need to modify the validation or use a different approach
|
||||
context 'when expires_at is blank' do
|
||||
it 'returns false' do
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(nil)
|
||||
expect(service.send(:token_valid?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is expired' do
|
||||
it 'returns false' do
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago)
|
||||
expect(service.send(:token_valid?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is valid' do
|
||||
it 'returns true' do
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(1.day.from_now)
|
||||
expect(service.send(:token_valid?)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#token_eligible_for_refresh?' do
|
||||
context 'when token is too new' do
|
||||
before do
|
||||
allow(instagram_channel).to receive(:updated_at).and_return(12.hours.ago)
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(5.days.from_now)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:token_eligible_for_refresh?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is not approaching expiry' do
|
||||
before do
|
||||
allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago)
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(20.days.from_now)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:token_eligible_for_refresh?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is expired' do
|
||||
before do
|
||||
allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago)
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:token_eligible_for_refresh?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user