Add: #600 NGルール (#602)

* Wip

* Wip

* Wip: History

* Wip: テストコード作成

* Fix test

* Wip

* Wip

* Wip

* Fix test

* Wip

* Wip

* Wip

* Wip

* なんとか完成、これから動作確認

* spell miss

* Change ng rule timings

* Fix test

* Wip

* Fix test

* Wip

* Fix form

* 表示まわりの改善
This commit is contained in:
KMY(雪あすか) 2024-02-26 17:45:41 +09:00 committed by GitHub
parent 0779c748a6
commit 7d96d5828e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2062 additions and 42 deletions

205
app/models/admin/ng_rule.rb Normal file
View file

@ -0,0 +1,205 @@
# frozen_string_literal: true
class Admin::NgRule
def initialize(ng_rule, account, **options)
@ng_rule = ng_rule
@account = account
@options = options
@uri = nil
end
def account_match?
return false if @account.local? && !@ng_rule.account_include_local
return false if !@account.local? && @ng_rule.account_allow_followed_by_local && followed_by_local_accounts?
if @account.local?
return false unless @ng_rule.account_include_local
else
return false unless text_match?(:account_domain, @account.domain, @ng_rule.account_domain)
end
text_match?(:account_username, @account.username, @ng_rule.account_username) &&
text_match?(:account_display_name, @account.display_name, @ng_rule.account_display_name) &&
text_match?(:account_note, @account.note, @ng_rule.account_note) &&
text_match?(:account_field_name, @account.fields&.map(&:name)&.join("\n"), @ng_rule.account_field_name) &&
text_match?(:account_field_value, @account.fields&.map(&:value)&.join("\n"), @ng_rule.account_field_value) &&
media_state_match?(:account_avatar_state, @account.avatar, @ng_rule.account_avatar_state) &&
media_state_match?(:account_header_state, @account.header, @ng_rule.account_header_state)
end
def status_match? # rubocop:disable Metrics/CyclomaticComplexity
return false if @ng_rule.status_allow_follower_mention && @options[:mention_to_following]
has_media = @options[:media_count].is_a?(Integer) && @options[:media_count].positive?
has_poll = @options[:poll_count].is_a?(Integer) && @options[:poll_count].positive?
has_mention = @options[:mention_count].is_a?(Integer) && @options[:mention_count].positive?
has_reference = @options[:reference_count].is_a?(Integer) && @options[:reference_count].positive?
@options = @options.merge({ searchability: 'unset' }) if @options[:searchability].nil?
text_match?(:status_spoiler_text, @options[:spoiler_text], @ng_rule.status_spoiler_text) &&
text_match?(:status_text, @options[:text], @ng_rule.status_text) &&
text_match?(:status_tag, @options[:tag_names]&.join("\n"), @ng_rule.status_tag) &&
enum_match?(:status_visibility, @options[:visibility], @ng_rule.status_visibility) &&
enum_match?(:status_searchability, @options[:searchability], @ng_rule.status_searchability) &&
state_match?(:status_sensitive_state, @options[:sensitive], @ng_rule.status_sensitive_state) &&
state_match?(:status_cw_state, @options[:spoiler_text].present?, @ng_rule.status_cw_state) &&
state_match?(:status_media_state, has_media, @ng_rule.status_media_state) &&
state_match?(:status_poll_state, has_poll, @ng_rule.status_poll_state) &&
state_match?(:status_quote_state, @options[:quote], @ng_rule.status_quote_state) &&
state_match?(:status_reply_state, @options[:reply], @ng_rule.status_reply_state) &&
state_match?(:status_mention_state, has_mention, @ng_rule.status_mention_state) &&
state_match?(:status_reference_state, has_reference, @ng_rule.status_reference_state) &&
value_over_threshold?(:status_tag_threshold, (@options[:tag_names] || []).size, @ng_rule.status_tag_threshold) &&
value_over_threshold?(:status_media_threshold, @options[:media_count], @ng_rule.status_media_threshold) &&
value_over_threshold?(:status_poll_threshold, @options[:poll_count], @ng_rule.status_poll_threshold) &&
value_over_threshold?(:status_mention_threshold, @options[:mention_count], @ng_rule.status_mention_threshold) &&
value_over_threshold?(:status_reference_threshold, @options[:reference_count], @ng_rule.status_reference_threshold)
end
def reaction_match?
recipient = @options[:recipient]
return false if @ng_rule.reaction_allow_follower && (recipient.id == @account.id || (!recipient.local? && !@account.local?) || recipient.following?(@account))
if @options[:reaction_type] == 'emoji_reaction'
enum_match?(:reaction_type, @options[:reaction_type], @ng_rule.reaction_type) &&
text_match?(:emoji_reaction_name, @options[:emoji_reaction_name], @ng_rule.emoji_reaction_name) &&
text_match?(:emoji_reaction_origin_domain, @options[:emoji_reaction_origin_domain], @ng_rule.emoji_reaction_origin_domain)
else
enum_match?(:reaction_type, @options[:reaction_type], @ng_rule.reaction_type)
end
end
def check_account_or_record!
return true unless account_match?
record!('account', @account.uri, 'account_create') if !@account.local? || @ng_rule.record_history_also_local
false
end
def check_status_or_record!
return true unless account_match? && status_match?
text = [@options[:spoiler_text], @options[:text]].compact_blank.join("\n\n")
data = {
media_count: @options[:media_count],
poll_count: @options[:poll_count],
url: @options[:url],
}
record!('status', @options[:uri], "status_#{@options[:reaction_type]}", text: text, data: data) if !@account.local? || @ng_rule.record_history_also_local
false
end
def check_reaction_or_record!
return true unless account_match? && reaction_match?
text = @options[:target_status].present? ? [@options[:target_status].spoiler_text, @options[:target_status].text].compact_blank.join("\n\n") : nil
data = {
url: @options[:target_status].present? ? @options[:target_status].url : nil,
}
record!('reaction', @options[:uri], "reaction_#{@options[:reaction_type]}", text: text, data: data) if !@account.local? || @ng_rule.record_history_also_local
false
end
def loggable_visibility?
visibility = @options[:target_status]&.visibility || @options[:visibility]
return true unless visibility
%i(public public_unlisted login unlisted).include?(visibility.to_sym)
end
def self.extract_test!(custom_ng_words)
detect_keyword?('test', custom_ng_words)
end
private
def followed_by_local_accounts?
Follow.exists?(account: Account.local, target_account: @account)
end
def record!(reason, uri, reason_action, **options)
opts = options.merge({
ng_rule: @ng_rule,
account: @account,
local: @account.local?,
reason: reason,
reason_action: reason_action,
uri: uri,
})
unless loggable_visibility?
opts = opts.merge({
text: nil,
uri: nil,
hidden: true,
})
end
NgRuleHistory.create!(**opts)
end
def text_match?(_reason, text, arr)
return true if arr.blank? || !text.is_a?(String)
detect_keyword?(text, arr)
end
def enum_match?(_reason, text, arr)
return true if !text.is_a?(String) || text.blank?
arr.include?(text)
end
def state_match?(_reason, exists, expected)
case expected.to_sym
when :needed
exists
when :no_needed
!exists
else
true
end
end
def media_state_match?(reason, media, expected)
state_match?(reason, media.present?, expected)
end
def value_over_threshold?(_reason, value, expected)
return true if !expected.is_a?(Integer) || expected.negative? || !value.is_a?(Integer)
value > expected
end
def detect_keyword?(text, arr)
Admin::NgRule.detect_keyword?(text, arr)
end
class << self
def string_to_array(text)
text.delete("\r").split("\n")
end
def detect_keyword(text, arr)
arr = string_to_array(arr) if arr.is_a?(String)
arr.detect { |word| include?(text, word) ? word : nil }
end
def detect_keyword?(text, arr)
detect_keyword(text, arr).present?
end
def include?(text, word)
if word.start_with?('?') && word.size >= 2
text =~ /#{word[1..]}/
else
text.include?(word)
end
end
end
end

114
app/models/ng_rule.rb Normal file
View file

@ -0,0 +1,114 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: ng_rules
#
# id :bigint(8) not null, primary key
# title :string default(""), not null
# available :boolean default(TRUE), not null
# record_history_also_local :boolean default(TRUE), not null
# account_domain :string default(""), not null
# account_username :string default(""), not null
# account_display_name :string default(""), not null
# account_note :string default(""), not null
# account_field_name :string default(""), not null
# account_field_value :string default(""), not null
# account_avatar_state :integer default("optional"), not null
# account_header_state :integer default("optional"), not null
# account_include_local :boolean default(TRUE), not null
# account_allow_followed_by_local :boolean default(FALSE), not null
# status_spoiler_text :string default(""), not null
# status_text :string default(""), not null
# status_tag :string default(""), not null
# status_visibility :string default([]), not null, is an Array
# status_searchability :string default([]), not null, is an Array
# status_media_state :integer default("optional"), not null
# status_sensitive_state :integer default("optional"), not null
# status_cw_state :integer default("optional"), not null
# status_poll_state :integer default("optional"), not null
# status_quote_state :integer default("optional"), not null
# status_reply_state :integer default("optional"), not null
# status_mention_state :integer default(0), not null
# status_reference_state :integer default(0), not null
# status_tag_threshold :integer default(-1), not null
# status_media_threshold :integer default(-1), not null
# status_poll_threshold :integer default(-1), not null
# status_mention_threshold :integer default(-1), not null
# status_allow_follower_mention :boolean default(TRUE), not null
# status_reference_threshold :integer default(-1), not null
# reaction_type :string default([]), not null, is an Array
# reaction_allow_follower :boolean default(TRUE), not null
# emoji_reaction_name :string default(""), not null
# emoji_reaction_origin_domain :string default(""), not null
# expires_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
class NgRule < ApplicationRecord
include Expireable
include Redisable
has_many :histories, class_name: 'NgRuleHistory', inverse_of: :ng_rule, dependent: :destroy
enum :account_avatar_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :account_avatar
enum :account_header_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :account_header
enum :status_sensitive_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_sensitive
enum :status_cw_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_cw
enum :status_quote_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_quote
enum :status_reply_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_reply
enum :status_media_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_media
enum :status_poll_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_poll
enum :status_mention_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_mention
enum :status_reference_state, { optional: 0, needed: 1, no_needed: 2 }, prefix: :status_reference
scope :enabled, -> { where(available: true) }
before_validation :clean_up_arrays
before_save :prepare_cache_invalidation!
before_destroy :prepare_cache_invalidation!
after_commit :invalidate_cache!
def self.cached_rules
active_rules = Rails.cache.fetch('ng_rules') do
NgRule.enabled.to_a
end
active_rules.reject { |ng_rule, _| ng_rule.expired? }
end
def expires_in
return @expires_in if defined?(@expires_in)
return nil if expires_at.nil?
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week, 2.weeks, 1.month, 3.months].find { |expires_in| expires_in.from_now >= expires_at }
end
def copy!
dup
end
def hit_count
Rails.cache.fetch("ng_rule:hit_count:#{id}", expires_in: 15.minutes) { NgRuleHistory.where(ng_rule_id: id).count }
end
private
def clean_up_arrays
self.status_visibility = Array(status_visibility).map(&:strip).filter_map(&:presence)
self.status_searchability = Array(status_searchability).map(&:strip).filter_map(&:presence)
self.reaction_type = Array(reaction_type).map(&:strip).filter_map(&:presence)
end
def prepare_cache_invalidation!
@should_invalidate_cache = true
end
def invalidate_cache!
return unless @should_invalidate_cache
@should_invalidate_cache = false
Rails.cache.delete('ng_rules')
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: ng_rule_histories
#
# id :bigint(8) not null, primary key
# ng_rule_id :bigint(8) not null
# account_id :bigint(8)
# text :string
# uri :string
# reason :integer not null
# reason_action :integer not null
# local :boolean default(TRUE), not null
# hidden :boolean default(FALSE), not null
# data :jsonb
# created_at :datetime not null
# updated_at :datetime not null
#
class NgRuleHistory < ApplicationRecord
enum :reason, { account: 0, status: 1, reaction: 2 }, prefix: :reason
enum :reason_action, {
account_create: 0,
status_create: 10,
status_edit: 11,
reaction_favourite: 20,
reaction_emoji_reaction: 21,
reaction_follow: 22,
reaction_reblog: 23,
reaction_vote: 24,
}, prefix: :reason_action
belongs_to :ng_rule
belongs_to :account
end

View file

@ -504,6 +504,10 @@ class Status < ApplicationRecord
%w(unset) + selectable_visibilities
end
def all_visibilities
visibilities.keys
end
def selectable_searchabilities
ss = searchabilities.keys - %w(unsupported)
ss -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility
@ -514,6 +518,10 @@ class Status < ApplicationRecord
searchabilities.keys - %w(public_unlisted unsupported)
end
def all_searchabilities
searchabilities.keys - %w(unlisted login unsupported)
end
def favourites_map(status_ids, account_id)
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
end