Browse Source

Add scheduled statuses (#9706)

Fix #340
Eugen Rochko 5 years ago
parent
commit
a49d43d112
29 changed files with 432 additions and 98 deletions
  1. 77 0
      app/controllers/api/v1/scheduled_statuses_controller.rb
  2. 5 4
      app/controllers/api/v1/statuses_controller.rb
  3. 1 0
      app/models/concerns/account_associations.rb
  4. 20 18
      app/models/media_attachment.rb
  5. 39 0
      app/models/scheduled_status.rb
  6. 11 0
      app/serializers/rest/scheduled_status_serializer.rb
  7. 126 43
      app/services/post_status_service.rb
  8. 1 0
      app/services/suspend_account_service.rb
  9. 24 0
      app/workers/publish_scheduled_status_worker.rb
  10. 19 0
      app/workers/scheduler/scheduled_statuses_scheduler.rb
  11. 4 0
      config/locales/en.yml
  12. 1 0
      config/routes.rb
  13. 3 0
      config/sidekiq.yml
  14. 9 0
      db/migrate/20190103124649_create_scheduled_statuses.rb
  15. 8 0
      db/migrate/20190103124754_add_scheduled_status_id_to_media_attachments.rb
  16. 13 1
      db/schema.rb
  17. 1 1
      spec/controllers/api/v1/conversations_controller_spec.rb
  18. 2 2
      spec/controllers/api/v1/notifications_controller_spec.rb
  19. 1 1
      spec/controllers/api/v1/timelines/home_controller_spec.rb
  20. 1 1
      spec/controllers/api/v1/timelines/list_controller_spec.rb
  21. 2 2
      spec/controllers/api/v1/timelines/public_controller_spec.rb
  22. 1 1
      spec/controllers/api/v1/timelines/tag_controller_spec.rb
  23. 4 0
      spec/fabricators/scheduled_status_fabricator.rb
  24. 3 3
      spec/lib/feed_manager_spec.rb
  25. 4 0
      spec/models/scheduled_status_spec.rb
  26. 2 2
      spec/services/batched_remove_status_service_spec.rb
  27. 26 18
      spec/services/post_status_service_spec.rb
  28. 1 1
      spec/services/remove_status_service_spec.rb
  29. 23 0
      spec/workers/publish_scheduled_status_worker_spec.rb

+ 77 - 0
app/controllers/api/v1/scheduled_statuses_controller.rb

@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+class Api::V1::ScheduledStatusesController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy]
+  before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy]
+
+  before_action :set_statuses, only: :index
+  before_action :set_status, except: :index
+
+  after_action :insert_pagination_headers, only: :index
+
+  def index
+    render json: @statuses, each_serializer: REST::ScheduledStatusSerializer
+  end
+
+  def show
+    render json: @status, serializer: REST::ScheduledStatusSerializer
+  end
+
+  def update
+    @status.update!(scheduled_status_params)
+    render json: @status, serializer: REST::ScheduledStatusSerializer
+  end
+
+  def destroy
+    @status.destroy!
+    render_empty
+  end
+
+  private
+
+  def set_statuses
+    @statuses = current_account.scheduled_statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
+  end
+
+  def set_status
+    @status = current_account.scheduled_statuses.find(params[:id])
+  end
+
+  def scheduled_status_params
+    params.permit(:scheduled_at)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    if records_continue?
+      api_v1_scheduled_statuses_url pagination_params(max_id: pagination_max_id)
+    end
+  end
+
+  def prev_path
+    unless @statuses.empty?
+      api_v1_scheduled_statuses_url pagination_params(min_id: pagination_since_id)
+    end
+  end
+
+  def records_continue?
+    @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+  end
+
+  def pagination_max_id
+    @statuses.last.id
+  end
+
+  def pagination_since_id
+    @statuses.first.id
+  end
+end

+ 5 - 4
app/controllers/api/v1/statuses_controller.rb

@@ -45,16 +45,17 @@ class Api::V1::StatusesController < Api::BaseController
 
 
   def create
   def create
     @status = PostStatusService.new.call(current_user.account,
     @status = PostStatusService.new.call(current_user.account,
-                                         status_params[:status],
-                                         status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]),
+                                         text: status_params[:status],
+                                         thread: status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]),
                                          media_ids: status_params[:media_ids],
                                          media_ids: status_params[:media_ids],
                                          sensitive: status_params[:sensitive],
                                          sensitive: status_params[:sensitive],
                                          spoiler_text: status_params[:spoiler_text],
                                          spoiler_text: status_params[:spoiler_text],
                                          visibility: status_params[:visibility],
                                          visibility: status_params[:visibility],
+                                         scheduled_at: status_params[:scheduled_at],
                                          application: doorkeeper_token.application,
                                          application: doorkeeper_token.application,
                                          idempotency: request.headers['Idempotency-Key'])
                                          idempotency: request.headers['Idempotency-Key'])
 
 
-    render json: @status, serializer: REST::StatusSerializer
+    render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
   end
   end
 
 
   def destroy
   def destroy
@@ -77,7 +78,7 @@ class Api::V1::StatusesController < Api::BaseController
   end
   end
 
 
   def status_params
   def status_params
-    params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
+    params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, :scheduled_at, media_ids: [])
   end
   end
 
 
   def pagination_params(core_params)
   def pagination_params(core_params)

+ 1 - 0
app/models/concerns/account_associations.rb

@@ -14,6 +14,7 @@ module AccountAssociations
     has_many :mentions, inverse_of: :account, dependent: :destroy
     has_many :mentions, inverse_of: :account, dependent: :destroy
     has_many :notifications, inverse_of: :account, dependent: :destroy
     has_many :notifications, inverse_of: :account, dependent: :destroy
     has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account
     has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account
+    has_many :scheduled_statuses, inverse_of: :account, dependent: :destroy
 
 
     # Pinned statuses
     # Pinned statuses
     has_many :status_pins, inverse_of: :account, dependent: :destroy
     has_many :status_pins, inverse_of: :account, dependent: :destroy

+ 20 - 18
app/models/media_attachment.rb

@@ -3,20 +3,21 @@
 #
 #
 # Table name: media_attachments
 # Table name: media_attachments
 #
 #
-#  id                :bigint(8)        not null, primary key
-#  status_id         :bigint(8)
-#  file_file_name    :string
-#  file_content_type :string
-#  file_file_size    :integer
-#  file_updated_at   :datetime
-#  remote_url        :string           default(""), not null
-#  created_at        :datetime         not null
-#  updated_at        :datetime         not null
-#  shortcode         :string
-#  type              :integer          default("image"), not null
-#  file_meta         :json
-#  account_id        :bigint(8)
-#  description       :text
+#  id                  :bigint(8)        not null, primary key
+#  status_id           :bigint(8)
+#  file_file_name      :string
+#  file_content_type   :string
+#  file_file_size      :integer
+#  file_updated_at     :datetime
+#  remote_url          :string           default(""), not null
+#  created_at          :datetime         not null
+#  updated_at          :datetime         not null
+#  shortcode           :string
+#  type                :integer          default("image"), not null
+#  file_meta           :json
+#  account_id          :bigint(8)
+#  description         :text
+#  scheduled_status_id :bigint(8)
 #
 #
 
 
 class MediaAttachment < ApplicationRecord
 class MediaAttachment < ApplicationRecord
@@ -76,8 +77,9 @@ class MediaAttachment < ApplicationRecord
   IMAGE_LIMIT = 8.megabytes
   IMAGE_LIMIT = 8.megabytes
   VIDEO_LIMIT = 40.megabytes
   VIDEO_LIMIT = 40.megabytes
 
 
-  belongs_to :account, inverse_of: :media_attachments, optional: true
-  belongs_to :status,  inverse_of: :media_attachments, optional: true
+  belongs_to :account,          inverse_of: :media_attachments, optional: true
+  belongs_to :status,           inverse_of: :media_attachments, optional: true
+  belongs_to :scheduled_status, inverse_of: :media_attachments, optional: true
 
 
   has_attached_file :file,
   has_attached_file :file,
                     styles: ->(f) { file_styles f },
                     styles: ->(f) { file_styles f },
@@ -94,8 +96,8 @@ class MediaAttachment < ApplicationRecord
   validates :account, presence: true
   validates :account, presence: true
   validates :description, length: { maximum: 420 }, if: :local?
   validates :description, length: { maximum: 420 }, if: :local?
 
 
-  scope :attached,   -> { where.not(status_id: nil) }
-  scope :unattached, -> { where(status_id: nil) }
+  scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
+  scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
   scope :local,      -> { where(remote_url: '') }
   scope :local,      -> { where(remote_url: '') }
   scope :remote,     -> { where.not(remote_url: '') }
   scope :remote,     -> { where.not(remote_url: '') }
 
 

+ 39 - 0
app/models/scheduled_status.rb

@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: scheduled_statuses
+#
+#  id           :bigint(8)        not null, primary key
+#  account_id   :bigint(8)
+#  scheduled_at :datetime
+#  params       :jsonb
+#
+
+class ScheduledStatus < ApplicationRecord
+  include Paginable
+
+  TOTAL_LIMIT = 300
+  DAILY_LIMIT = 25
+
+  belongs_to :account, inverse_of: :scheduled_statuses
+  has_many :media_attachments, inverse_of: :scheduled_status, dependent: :destroy
+
+  validate :validate_future_date
+  validate :validate_total_limit
+  validate :validate_daily_limit
+
+  private
+
+  def validate_future_date
+    errors.add(:scheduled_at, I18n.t('scheduled_statuses.too_soon')) if scheduled_at.present? && scheduled_at <= Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET
+  end
+
+  def validate_total_limit
+    errors.add(:base, I18n.t('scheduled_statuses.over_total_limit', limit: TOTAL_LIMIT)) if account.scheduled_statuses.count >= TOTAL_LIMIT
+  end
+
+  def validate_daily_limit
+    errors.add(:base, I18n.t('scheduled_statuses.over_daily_limit', limit: DAILY_LIMIT)) if account.scheduled_statuses.where('scheduled_at::date = ?::date', scheduled_at).count >= DAILY_LIMIT
+  end
+end

+ 11 - 0
app/serializers/rest/scheduled_status_serializer.rb

@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class REST::ScheduledStatusSerializer < ActiveModel::Serializer
+  attributes :id, :scheduled_at
+
+  has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
+
+  def id
+    object.id.to_s
+  end
+end

+ 126 - 43
app/services/post_status_service.rb

@@ -1,71 +1,96 @@
 # frozen_string_literal: true
 # frozen_string_literal: true
 
 
 class PostStatusService < BaseService
 class PostStatusService < BaseService
+  MIN_SCHEDULE_OFFSET = 5.minutes.freeze
+
   # Post a text status update, fetch and notify remote users mentioned
   # Post a text status update, fetch and notify remote users mentioned
   # @param [Account] account Account from which to post
   # @param [Account] account Account from which to post
-  # @param [String] text Message
-  # @param [Status] in_reply_to Optional status to reply to
   # @param [Hash] options
   # @param [Hash] options
+  # @option [String] :text Message
+  # @option [Status] :thread Optional status to reply to
   # @option [Boolean] :sensitive
   # @option [Boolean] :sensitive
   # @option [String] :visibility
   # @option [String] :visibility
   # @option [String] :spoiler_text
   # @option [String] :spoiler_text
+  # @option [String] :language
+  # @option [String] :scheduled_at
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
   # @option [Doorkeeper::Application] :application
   # @option [Doorkeeper::Application] :application
   # @option [String] :idempotency Optional idempotency key
   # @option [String] :idempotency Optional idempotency key
   # @return [Status]
   # @return [Status]
-  def call(account, text, in_reply_to = nil, **options)
-    if options[:idempotency].present?
-      existing_id = redis.get("idempotency:status:#{account.id}:#{options[:idempotency]}")
-      return Status.find(existing_id) if existing_id
+  def call(account, options = {})
+    @account     = account
+    @options     = options
+    @text        = @options[:text] || ''
+    @in_reply_to = @options[:thread]
+
+    return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
+
+    validate_media!
+    preprocess_attributes!
+
+    if scheduled?
+      schedule_status!
+    else
+      process_status!
+      postprocess_status!
+      bump_potential_friendship!
     end
     end
 
 
-    media  = validate_media!(options[:media_ids])
-    status = nil
-    text   = options.delete(:spoiler_text) if text.blank? && options[:spoiler_text].present?
+    redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
 
 
-    visibility = options[:visibility] || account.user&.setting_default_privacy
-    visibility = :unlisted if visibility == :public && account.silenced
+    @status
+  end
 
 
-    ApplicationRecord.transaction do
-      status = account.statuses.create!(text: text,
-                                        media_attachments: media || [],
-                                        thread: in_reply_to,
-                                        sensitive: (options[:sensitive].nil? ? account.user&.setting_default_sensitive : options[:sensitive]) || options[:spoiler_text].present?,
-                                        spoiler_text: options[:spoiler_text] || '',
-                                        visibility: visibility,
-                                        language: language_from_option(options[:language]) || account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(text, account),
-                                        application: options[:application])
-    end
+  private
 
 
-    process_hashtags_service.call(status)
-    process_mentions_service.call(status)
+  def preprocess_attributes!
+    @text         = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
+    @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
+    @visibility   = :unlisted if @visibility == :public && @account.silenced
+    @scheduled_at = @options[:scheduled_at]&.to_datetime
+    @scheduled_at = nil if scheduled_in_the_past?
+  end
 
 
-    LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
-    DistributionWorker.perform_async(status.id)
-    Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
-    ActivityPub::DistributionWorker.perform_async(status.id)
+  def process_status!
+    # The following transaction block is needed to wrap the UPDATEs to
+    # the media attachments when the status is created
 
 
-    if options[:idempotency].present?
-      redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id)
+    ApplicationRecord.transaction do
+      @status = @account.statuses.create!(status_attributes)
     end
     end
 
 
-    bump_potential_friendship(account, status)
-
-    status
+    process_hashtags_service.call(@status)
+    process_mentions_service.call(@status)
   end
   end
 
 
-  private
+  def schedule_status!
+    if @account.statuses.build(status_attributes).valid?
+      # The following transaction block is needed to wrap the UPDATEs to
+      # the media attachments when the scheduled status is created
 
 
-  def validate_media!(media_ids)
-    return if media_ids.blank? || !media_ids.is_a?(Enumerable)
+      ApplicationRecord.transaction do
+        @status = @account.scheduled_statuses.create!(scheduled_status_attributes)
+      end
+    else
+      raise ActiveRecord::RecordInvalid
+    end
+  end
+
+  def postprocess_status!
+    LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
+    DistributionWorker.perform_async(@status.id)
+    Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id)
+    ActivityPub::DistributionWorker.perform_async(@status.id)
+  end
 
 
-    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if media_ids.size > 4
+  def validate_media!
+    return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
 
 
-    media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i))
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4
 
 
-    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media.size > 1 && media.find(&:video?)
+    @media = MediaAttachment.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i))
 
 
-    media
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:video?)
   end
   end
 
 
   def language_from_option(str)
   def language_from_option(str)
@@ -84,10 +109,68 @@ class PostStatusService < BaseService
     Redis.current
     Redis.current
   end
   end
 
 
-  def bump_potential_friendship(account, status)
-    return if !status.reply? || account.id == status.in_reply_to_account_id
+  def scheduled?
+    @scheduled_at.present?
+  end
+
+  def idempotency_key
+    "idempotency:status:#{@account.id}:#{@options[:idempotency]}"
+  end
+
+  def idempotency_given?
+    @options[:idempotency].present?
+  end
+
+  def idempotency_duplicate
+    if scheduled?
+      @account.schedule_statuses.find(@idempotency_duplicate)
+    else
+      @account.statuses.find(@idempotency_duplicate)
+    end
+  end
+
+  def idempotency_duplicate?
+    @idempotency_duplicate = redis.get(idempotency_key)
+  end
+
+  def scheduled_in_the_past?
+    @scheduled_at.present? && @scheduled_at <= Time.now.utc + MIN_SCHEDULE_OFFSET
+  end
+
+  def bump_potential_friendship!
+    return if !@status.reply? || @account.id == @status.in_reply_to_account_id
     ActivityTracker.increment('activity:interactions')
     ActivityTracker.increment('activity:interactions')
-    return if account.following?(status.in_reply_to_account_id)
-    PotentialFriendshipTracker.record(account.id, status.in_reply_to_account_id, :reply)
+    return if @account.following?(@status.in_reply_to_account_id)
+    PotentialFriendshipTracker.record(@account.id, @status.in_reply_to_account_id, :reply)
+  end
+
+  def status_attributes
+    {
+      text: @text,
+      media_attachments: @media || [],
+      thread: @in_reply_to,
+      sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?,
+      spoiler_text: @options[:spoiler_text] || '',
+      visibility: @visibility,
+      language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
+      application: @options[:application],
+    }
+  end
+
+  def scheduled_status_attributes
+    {
+      scheduled_at: @scheduled_at,
+      media_attachments: @media || [],
+      params: scheduled_options,
+    }
+  end
+
+  def scheduled_options
+    @options.tap do |options_hash|
+      options_hash[:in_reply_to_status_id] = options_hash.delete(:thread)&.id
+      options_hash[:application_id]        = options_hash.delete(:application)&.id
+      options_hash[:scheduled_at]          = nil
+      options_hash[:idempotency]           = nil
+    end
   end
   end
 end
 end

+ 1 - 0
app/services/suspend_account_service.rb

@@ -20,6 +20,7 @@ class SuspendAccountService < BaseService
     owned_lists
     owned_lists
     passive_relationships
     passive_relationships
     report_notes
     report_notes
+    scheduled_statuses
     status_pins
     status_pins
     stream_entries
     stream_entries
     subscriptions
     subscriptions

+ 24 - 0
app/workers/publish_scheduled_status_worker.rb

@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class PublishScheduledStatusWorker
+  include Sidekiq::Worker
+
+  def perform(scheduled_status_id)
+    scheduled_status = ScheduledStatus.find(scheduled_status_id)
+    scheduled_status.destroy!
+
+    PostStatusService.new.call(
+      scheduled_status.account,
+      options_with_objects(scheduled_status.params.with_indifferent_access)
+    )
+  rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid
+    true
+  end
+
+  def options_with_objects(options)
+    options.tap do |options_hash|
+      options_hash[:application] = Doorkeeper::Application.find(options_hash.delete(:application_id)) if options[:application_id]
+      options_hash[:thread]      = Status.find(options_hash.delete(:in_reply_to_status_id)) if options_hash[:in_reply_to_status_id]
+    end
+  end
+end

+ 19 - 0
app/workers/scheduler/scheduled_statuses_scheduler.rb

@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Scheduler::ScheduledStatusesScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options unique: :until_executed, retry: 0
+
+  def perform
+    due_statuses.find_each do |scheduled_status|
+      PublishScheduledStatusWorker.perform_at(scheduled_status.scheduled_at)
+    end
+  end
+
+  private
+
+  def due_statuses
+    ScheduledStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
+  end
+end

+ 4 - 0
config/locales/en.yml

@@ -728,6 +728,10 @@ en:
     error: Error
     error: Error
     title: Title
     title: Title
     unfollowed: Unfollowed
     unfollowed: Unfollowed
+  scheduled_statuses:
+    over_daily_limit: You have exceeded the limit of %{limit} scheduled toots for that day
+    over_total_limit: You have exceeded the limit of %{limit} scheduled toots
+    too_soon: The scheduled date must be in the future
   sessions:
   sessions:
     activity: Last activity
     activity: Last activity
     browser: Browser
     browser: Browser

+ 1 - 0
config/routes.rb

@@ -283,6 +283,7 @@ Rails.application.routes.draw do
       resources :streaming, only: [:index]
       resources :streaming, only: [:index]
       resources :custom_emojis, only: [:index]
       resources :custom_emojis, only: [:index]
       resources :suggestions, only: [:index, :destroy]
       resources :suggestions, only: [:index, :destroy]
+      resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
 
 
       resources :conversations, only: [:index, :destroy] do
       resources :conversations, only: [:index, :destroy] do
         member do
         member do

+ 3 - 0
config/sidekiq.yml

@@ -6,6 +6,9 @@
   - [mailers, 2]
   - [mailers, 2]
   - [pull]
   - [pull]
 :schedule:
 :schedule:
+  scheduled_statuses_scheduler:
+    every: '5m'
+    class: Scheduler::ScheduledStatusesScheduler
   subscriptions_scheduler:
   subscriptions_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(4..6) %> * * *'
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(4..6) %> * * *'
     class: Scheduler::SubscriptionsScheduler
     class: Scheduler::SubscriptionsScheduler

+ 9 - 0
db/migrate/20190103124649_create_scheduled_statuses.rb

@@ -0,0 +1,9 @@
+class CreateScheduledStatuses < ActiveRecord::Migration[5.2]
+  def change
+    create_table :scheduled_statuses do |t|
+      t.belongs_to :account, foreign_key: { on_delete: :cascade }
+      t.datetime :scheduled_at, index: true
+      t.jsonb :params
+    end
+  end
+end

+ 8 - 0
db/migrate/20190103124754_add_scheduled_status_id_to_media_attachments.rb

@@ -0,0 +1,8 @@
+class AddScheduledStatusIdToMediaAttachments < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    add_reference :media_attachments, :scheduled_status, foreign_key: { on_delete: :nullify }, index: false
+    add_index :media_attachments, :scheduled_status_id, algorithm: :concurrently
+  end
+end

+ 13 - 1
db/schema.rb

@@ -10,7 +10,7 @@
 #
 #
 # It's strongly recommended that you check this file into your version control system.
 # It's strongly recommended that you check this file into your version control system.
 
 
-ActiveRecord::Schema.define(version: 2018_12_26_021420) do
+ActiveRecord::Schema.define(version: 2019_01_03_124754) do
 
 
   # These are extensions that must be enabled in order to support this database
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
   enable_extension "plpgsql"
@@ -336,7 +336,9 @@ ActiveRecord::Schema.define(version: 2018_12_26_021420) do
     t.json "file_meta"
     t.json "file_meta"
     t.bigint "account_id"
     t.bigint "account_id"
     t.text "description"
     t.text "description"
+    t.bigint "scheduled_status_id"
     t.index ["account_id"], name: "index_media_attachments_on_account_id"
     t.index ["account_id"], name: "index_media_attachments_on_account_id"
+    t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
     t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
     t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
     t.index ["status_id"], name: "index_media_attachments_on_status_id"
     t.index ["status_id"], name: "index_media_attachments_on_status_id"
   end
   end
@@ -487,6 +489,14 @@ ActiveRecord::Schema.define(version: 2018_12_26_021420) do
     t.index ["target_account_id"], name: "index_reports_on_target_account_id"
     t.index ["target_account_id"], name: "index_reports_on_target_account_id"
   end
   end
 
 
+  create_table "scheduled_statuses", force: :cascade do |t|
+    t.bigint "account_id"
+    t.datetime "scheduled_at"
+    t.jsonb "params"
+    t.index ["account_id"], name: "index_scheduled_statuses_on_account_id"
+    t.index ["scheduled_at"], name: "index_scheduled_statuses_on_scheduled_at"
+  end
+
   create_table "session_activations", force: :cascade do |t|
   create_table "session_activations", force: :cascade do |t|
     t.string "session_id", null: false
     t.string "session_id", null: false
     t.datetime "created_at", null: false
     t.datetime "created_at", null: false
@@ -700,6 +710,7 @@ ActiveRecord::Schema.define(version: 2018_12_26_021420) do
   add_foreign_key "list_accounts", "lists", on_delete: :cascade
   add_foreign_key "list_accounts", "lists", on_delete: :cascade
   add_foreign_key "lists", "accounts", on_delete: :cascade
   add_foreign_key "lists", "accounts", on_delete: :cascade
   add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify
   add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify
+  add_foreign_key "media_attachments", "scheduled_statuses", on_delete: :nullify
   add_foreign_key "media_attachments", "statuses", on_delete: :nullify
   add_foreign_key "media_attachments", "statuses", on_delete: :nullify
   add_foreign_key "mentions", "accounts", name: "fk_970d43f9d1", on_delete: :cascade
   add_foreign_key "mentions", "accounts", name: "fk_970d43f9d1", on_delete: :cascade
   add_foreign_key "mentions", "statuses", on_delete: :cascade
   add_foreign_key "mentions", "statuses", on_delete: :cascade
@@ -718,6 +729,7 @@ ActiveRecord::Schema.define(version: 2018_12_26_021420) do
   add_foreign_key "reports", "accounts", column: "assigned_account_id", on_delete: :nullify
   add_foreign_key "reports", "accounts", column: "assigned_account_id", on_delete: :nullify
   add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade
   add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade
   add_foreign_key "reports", "accounts", name: "fk_4b81f7522c", on_delete: :cascade
   add_foreign_key "reports", "accounts", name: "fk_4b81f7522c", on_delete: :cascade
+  add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade
   add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade
   add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade
   add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade
   add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade
   add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
   add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade

+ 1 - 1
spec/controllers/api/v1/conversations_controller_spec.rb

@@ -15,7 +15,7 @@ RSpec.describe Api::V1::ConversationsController, type: :controller do
     let(:scopes) { 'read:statuses' }
     let(:scopes) { 'read:statuses' }
 
 
     before do
     before do
-      PostStatusService.new.call(other.account, 'Hey @alice', nil, visibility: 'direct')
+      PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct')
     end
     end
 
 
     it 'returns http success' do
     it 'returns http success' do

+ 2 - 2
spec/controllers/api/v1/notifications_controller_spec.rb

@@ -50,9 +50,9 @@ RSpec.describe Api::V1::NotificationsController, type: :controller do
     let(:scopes) { 'read:notifications' }
     let(:scopes) { 'read:notifications' }
 
 
     before do
     before do
-      first_status = PostStatusService.new.call(user.account, 'Test')
+      first_status = PostStatusService.new.call(user.account, text: 'Test')
       @reblog_of_first_status = ReblogService.new.call(other.account, first_status)
       @reblog_of_first_status = ReblogService.new.call(other.account, first_status)
-      mentioning_status = PostStatusService.new.call(other.account, 'Hello @alice')
+      mentioning_status = PostStatusService.new.call(other.account, text: 'Hello @alice')
       @mention_from_status = mentioning_status.mentions.first
       @mention_from_status = mentioning_status.mentions.first
       @favourite = FavouriteService.new.call(other.account, first_status)
       @favourite = FavouriteService.new.call(other.account, first_status)
       @follow = FollowService.new.call(other.account, 'alice')
       @follow = FollowService.new.call(other.account, 'alice')

+ 1 - 1
spec/controllers/api/v1/timelines/home_controller_spec.rb

@@ -17,7 +17,7 @@ describe Api::V1::Timelines::HomeController do
     describe 'GET #show' do
     describe 'GET #show' do
       before do
       before do
         follow = Fabricate(:follow, account: user.account)
         follow = Fabricate(:follow, account: user.account)
-        PostStatusService.new.call(follow.target_account, 'New status for user home timeline.')
+        PostStatusService.new.call(follow.target_account, text: 'New status for user home timeline.')
       end
       end
 
 
       it 'returns http success' do
       it 'returns http success' do

+ 1 - 1
spec/controllers/api/v1/timelines/list_controller_spec.rb

@@ -19,7 +19,7 @@ describe Api::V1::Timelines::ListController do
       before do
       before do
         follow = Fabricate(:follow, account: user.account)
         follow = Fabricate(:follow, account: user.account)
         list.accounts << follow.target_account
         list.accounts << follow.target_account
-        PostStatusService.new.call(follow.target_account, 'New status for user home timeline.')
+        PostStatusService.new.call(follow.target_account, text: 'New status for user home timeline.')
       end
       end
 
 
       it 'returns http success' do
       it 'returns http success' do

+ 2 - 2
spec/controllers/api/v1/timelines/public_controller_spec.rb

@@ -16,7 +16,7 @@ describe Api::V1::Timelines::PublicController do
 
 
     describe 'GET #show' do
     describe 'GET #show' do
       before do
       before do
-        PostStatusService.new.call(user.account, 'New status from user for federated public timeline.')
+        PostStatusService.new.call(user.account, text: 'New status from user for federated public timeline.')
       end
       end
 
 
       it 'returns http success' do
       it 'returns http success' do
@@ -29,7 +29,7 @@ describe Api::V1::Timelines::PublicController do
 
 
     describe 'GET #show with local only' do
     describe 'GET #show with local only' do
       before do
       before do
-        PostStatusService.new.call(user.account, 'New status from user for local public timeline.')
+        PostStatusService.new.call(user.account, text: 'New status from user for local public timeline.')
       end
       end
 
 
       it 'returns http success' do
       it 'returns http success' do

+ 1 - 1
spec/controllers/api/v1/timelines/tag_controller_spec.rb

@@ -16,7 +16,7 @@ describe Api::V1::Timelines::TagController do
 
 
     describe 'GET #show' do
     describe 'GET #show' do
       before do
       before do
-        PostStatusService.new.call(user.account, 'It is a #test')
+        PostStatusService.new.call(user.account, text: 'It is a #test')
       end
       end
 
 
       it 'returns http success' do
       it 'returns http success' do

+ 4 - 0
spec/fabricators/scheduled_status_fabricator.rb

@@ -0,0 +1,4 @@
+Fabricator(:scheduled_status) do
+  account
+  scheduled_at { 20.hours.from_now }
+end

+ 3 - 3
spec/lib/feed_manager_spec.rb

@@ -108,14 +108,14 @@ RSpec.describe FeedManager do
 
 
       it 'returns false for status by followee mentioning another account' do
       it 'returns false for status by followee mentioning another account' do
         bob.follow!(alice)
         bob.follow!(alice)
-        status = PostStatusService.new.call(alice, 'Hey @jeff')
+        status = PostStatusService.new.call(alice, text: 'Hey @jeff')
         expect(FeedManager.instance.filter?(:home, status, bob.id)).to be false
         expect(FeedManager.instance.filter?(:home, status, bob.id)).to be false
       end
       end
 
 
       it 'returns true for status by followee mentioning blocked account' do
       it 'returns true for status by followee mentioning blocked account' do
         bob.block!(jeff)
         bob.block!(jeff)
         bob.follow!(alice)
         bob.follow!(alice)
-        status = PostStatusService.new.call(alice, 'Hey @jeff')
+        status = PostStatusService.new.call(alice, text: 'Hey @jeff')
         expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
         expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
       end
       end
 
 
@@ -155,7 +155,7 @@ RSpec.describe FeedManager do
     context 'for mentions feed' do
     context 'for mentions feed' do
       it 'returns true for status that mentions blocked account' do
       it 'returns true for status that mentions blocked account' do
         bob.block!(jeff)
         bob.block!(jeff)
-        status = PostStatusService.new.call(alice, 'Hey @jeff')
+        status = PostStatusService.new.call(alice, text: 'Hey @jeff')
         expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
         expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
       end
       end
 
 

+ 4 - 0
spec/models/scheduled_status_spec.rb

@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe ScheduledStatus, type: :model do
+end

+ 2 - 2
spec/services/batched_remove_status_service_spec.rb

@@ -8,8 +8,8 @@ RSpec.describe BatchedRemoveStatusService, type: :service do
   let!(:jeff)   { Fabricate(:user).account }
   let!(:jeff)   { Fabricate(:user).account }
   let!(:hank)   { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
   let!(:hank)   { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
 
 
-  let(:status1) { PostStatusService.new.call(alice, 'Hello @bob@example.com') }
-  let(:status2) { PostStatusService.new.call(alice, 'Another status') }
+  let(:status1) { PostStatusService.new.call(alice, text: 'Hello @bob@example.com') }
+  let(:status2) { PostStatusService.new.call(alice, text: 'Another status') }
 
 
   before do
   before do
     allow(Redis.current).to receive_messages(publish: nil)
     allow(Redis.current).to receive_messages(publish: nil)

+ 26 - 18
spec/services/post_status_service_spec.rb

@@ -7,7 +7,7 @@ RSpec.describe PostStatusService, type: :service do
     account = Fabricate(:account)
     account = Fabricate(:account)
     text = "test status update"
     text = "test status update"
 
 
-    status = subject.call(account, text)
+    status = subject.call(account, text: text)
 
 
     expect(status).to be_persisted
     expect(status).to be_persisted
     expect(status.text).to eq text
     expect(status.text).to eq text
@@ -18,20 +18,31 @@ RSpec.describe PostStatusService, type: :service do
     account = Fabricate(:account)
     account = Fabricate(:account)
     text = "test status update"
     text = "test status update"
 
 
-    status = subject.call(account, text, in_reply_to_status)
+    status = subject.call(account, text: text, thread: in_reply_to_status)
 
 
     expect(status).to be_persisted
     expect(status).to be_persisted
     expect(status.text).to eq text
     expect(status.text).to eq text
     expect(status.thread).to eq in_reply_to_status
     expect(status.thread).to eq in_reply_to_status
   end
   end
 
 
+  it 'schedules a status' do
+    account = Fabricate(:account)
+    future  = Time.now.utc + 2.hours
+
+    status = subject.call(account, text: 'Hi future!', scheduled_at: future)
+
+    expect(status).to be_a ScheduledStatus
+    expect(status.scheduled_at).to eq future
+    expect(status.params['text']).to eq 'Hi future!'
+  end
+
   it 'creates response to the original status of boost' do
   it 'creates response to the original status of boost' do
     boosted_status = Fabricate(:status)
     boosted_status = Fabricate(:status)
     in_reply_to_status = Fabricate(:status, reblog: boosted_status)
     in_reply_to_status = Fabricate(:status, reblog: boosted_status)
     account = Fabricate(:account)
     account = Fabricate(:account)
     text = "test status update"
     text = "test status update"
 
 
-    status = subject.call(account, text, in_reply_to_status)
+    status = subject.call(account, text: text, thread: in_reply_to_status)
 
 
     expect(status).to be_persisted
     expect(status).to be_persisted
     expect(status.text).to eq text
     expect(status.text).to eq text
@@ -69,7 +80,7 @@ RSpec.describe PostStatusService, type: :service do
   end
   end
 
 
   it 'creates a status with limited visibility for silenced users' do
   it 'creates a status with limited visibility for silenced users' do
-    status = subject.call(Fabricate(:account, silenced: true), 'test', nil, visibility: :public)
+    status = subject.call(Fabricate(:account, silenced: true), text: 'test', visibility: :public)
 
 
     expect(status).to be_persisted
     expect(status).to be_persisted
     expect(status.visibility).to eq "unlisted"
     expect(status.visibility).to eq "unlisted"
@@ -88,7 +99,7 @@ RSpec.describe PostStatusService, type: :service do
     account = Fabricate(:account)
     account = Fabricate(:account)
     text = 'This is an English text.'
     text = 'This is an English text.'
 
 
-    status = subject.call(account, text)
+    status = subject.call(account, text: text)
 
 
     expect(status.language).to eq 'en'
     expect(status.language).to eq 'en'
   end
   end
@@ -99,7 +110,7 @@ RSpec.describe PostStatusService, type: :service do
     allow(ProcessMentionsService).to receive(:new).and_return(mention_service)
     allow(ProcessMentionsService).to receive(:new).and_return(mention_service)
     account = Fabricate(:account)
     account = Fabricate(:account)
 
 
-    status = subject.call(account, "test status update")
+    status = subject.call(account, text: "test status update")
 
 
     expect(ProcessMentionsService).to have_received(:new)
     expect(ProcessMentionsService).to have_received(:new)
     expect(mention_service).to have_received(:call).with(status)
     expect(mention_service).to have_received(:call).with(status)
@@ -111,7 +122,7 @@ RSpec.describe PostStatusService, type: :service do
     allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service)
     allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service)
     account = Fabricate(:account)
     account = Fabricate(:account)
 
 
-    status = subject.call(account, "test status update")
+    status = subject.call(account, text: "test status update")
 
 
     expect(ProcessHashtagsService).to have_received(:new)
     expect(ProcessHashtagsService).to have_received(:new)
     expect(hashtags_service).to have_received(:call).with(status)
     expect(hashtags_service).to have_received(:call).with(status)
@@ -124,7 +135,7 @@ RSpec.describe PostStatusService, type: :service do
 
 
     account = Fabricate(:account)
     account = Fabricate(:account)
 
 
-    status = subject.call(account, "test status update")
+    status = subject.call(account, text: "test status update")
 
 
     expect(DistributionWorker).to have_received(:perform_async).with(status.id)
     expect(DistributionWorker).to have_received(:perform_async).with(status.id)
     expect(Pubsubhubbub::DistributionWorker).to have_received(:perform_async).with(status.stream_entry.id)
     expect(Pubsubhubbub::DistributionWorker).to have_received(:perform_async).with(status.stream_entry.id)
@@ -135,7 +146,7 @@ RSpec.describe PostStatusService, type: :service do
     allow(LinkCrawlWorker).to receive(:perform_async)
     allow(LinkCrawlWorker).to receive(:perform_async)
     account = Fabricate(:account)
     account = Fabricate(:account)
 
 
-    status = subject.call(account, "test status update")
+    status = subject.call(account, text: "test status update")
 
 
     expect(LinkCrawlWorker).to have_received(:perform_async).with(status.id)
     expect(LinkCrawlWorker).to have_received(:perform_async).with(status.id)
   end
   end
@@ -146,8 +157,7 @@ RSpec.describe PostStatusService, type: :service do
 
 
     status = subject.call(
     status = subject.call(
       account,
       account,
-      "test status update",
-      nil,
+      text: "test status update",
       media_ids: [media.id],
       media_ids: [media.id],
     )
     )
 
 
@@ -160,8 +170,7 @@ RSpec.describe PostStatusService, type: :service do
     expect do
     expect do
       subject.call(
       subject.call(
         account,
         account,
-        "test status update",
-        nil,
+        text: "test status update",
         media_ids: [
         media_ids: [
           Fabricate(:media_attachment, account: account),
           Fabricate(:media_attachment, account: account),
           Fabricate(:media_attachment, account: account),
           Fabricate(:media_attachment, account: account),
@@ -182,8 +191,7 @@ RSpec.describe PostStatusService, type: :service do
     expect do
     expect do
       subject.call(
       subject.call(
         account,
         account,
-        "test status update",
-        nil,
+        text: "test status update",
         media_ids: [
         media_ids: [
           Fabricate(:media_attachment, type: :video, account: account),
           Fabricate(:media_attachment, type: :video, account: account),
           Fabricate(:media_attachment, type: :image, account: account),
           Fabricate(:media_attachment, type: :image, account: account),
@@ -197,12 +205,12 @@ RSpec.describe PostStatusService, type: :service do
 
 
   it 'returns existing status when used twice with idempotency key' do
   it 'returns existing status when used twice with idempotency key' do
     account = Fabricate(:account)
     account = Fabricate(:account)
-    status1 = subject.call(account, 'test', nil, idempotency: 'meepmeep')
-    status2 = subject.call(account, 'test', nil, idempotency: 'meepmeep')
+    status1 = subject.call(account, text: 'test', idempotency: 'meepmeep')
+    status2 = subject.call(account, text: 'test', idempotency: 'meepmeep')
     expect(status2.id).to eq status1.id
     expect(status2.id).to eq status1.id
   end
   end
 
 
   def create_status_with_options(**options)
   def create_status_with_options(**options)
-    subject.call(Fabricate(:account), 'test', nil, options)
+    subject.call(Fabricate(:account), options.merge(text: 'test'))
   end
   end
 end
 end

+ 1 - 1
spec/services/remove_status_service_spec.rb

@@ -19,7 +19,7 @@ RSpec.describe RemoveStatusService, type: :service do
     jeff.follow!(alice)
     jeff.follow!(alice)
     hank.follow!(alice)
     hank.follow!(alice)
 
 
-    @status = PostStatusService.new.call(alice, 'Hello @bob@example.com')
+    @status = PostStatusService.new.call(alice, text: 'Hello @bob@example.com')
     Fabricate(:status, account: bill, reblog: @status, uri: 'hoge')
     Fabricate(:status, account: bill, reblog: @status, uri: 'hoge')
     subject.call(@status)
     subject.call(@status)
   end
   end

+ 23 - 0
spec/workers/publish_scheduled_status_worker_spec.rb

@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe PublishScheduledStatusWorker do
+  subject { described_class.new }
+
+  let(:scheduled_status) { Fabricate(:scheduled_status, params: { text: 'Hello world, future!' }) }
+
+  describe 'perform' do
+    before do
+      subject.perform(scheduled_status.id)
+    end
+
+    it 'creates a status' do
+      expect(scheduled_status.account.statuses.first.text).to eq 'Hello world, future!'
+    end
+
+    it 'removes the scheduled status' do
+      expect(ScheduledStatus.find_by(id: scheduled_status.id)).to be_nil
+    end
+  end
+end