├── icon ├── bot.png ├── pin.png ├── direct.png ├── private.png ├── unlisted.png ├── libre-gui-lock.svg ├── libre-gui-unlock.svg └── libre-gui-email.svg ├── patch.rb ├── util.rb ├── model ├── model.rb ├── application.rb ├── entity_class.rb ├── mention.rb ├── tag.rb ├── emoji.rb ├── attachment.rb ├── account_profile.rb ├── account.rb ├── instance.rb ├── world.rb └── status.rb ├── .mikutter.yml ├── score.rb ├── instance_setting_list.rb ├── LICENSE ├── .gitignore ├── README.md ├── extractcondition.rb ├── rest.rb ├── setting.rb ├── subparts_status_info.rb ├── parser.rb ├── sse_client.rb ├── api.rb ├── worldon.rb ├── sse_stream.rb └── spell.rb /icon/bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cobodo/mikutter-worldon/HEAD/icon/bot.png -------------------------------------------------------------------------------- /icon/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cobodo/mikutter-worldon/HEAD/icon/pin.png -------------------------------------------------------------------------------- /icon/direct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cobodo/mikutter-worldon/HEAD/icon/direct.png -------------------------------------------------------------------------------- /icon/private.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cobodo/mikutter-worldon/HEAD/icon/private.png -------------------------------------------------------------------------------- /icon/unlisted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cobodo/mikutter-worldon/HEAD/icon/unlisted.png -------------------------------------------------------------------------------- /patch.rb: -------------------------------------------------------------------------------- 1 | class Gtk::PostBox 2 | # @toのアクセサを生やす 3 | def worldon_get_reply_to 4 | @to&.first 5 | end 6 | end 7 | 8 | -------------------------------------------------------------------------------- /util.rb: -------------------------------------------------------------------------------- 1 | module Plugin::Worldon 2 | class Util 3 | class << self 4 | def deep_dup(obj) 5 | Marshal.load(Marshal.dump(obj)) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /model/model.rb: -------------------------------------------------------------------------------- 1 | require_relative 'application' 2 | require_relative 'emoji' 3 | require_relative 'attachment' 4 | require_relative 'mention' 5 | require_relative 'tag' 6 | require_relative 'account' 7 | require_relative 'account_profile' 8 | require_relative 'instance' 9 | require_relative 'status' 10 | require_relative 'world' 11 | -------------------------------------------------------------------------------- /.mikutter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | slug: :worldon 3 | depends: 4 | mikutter: 3.7.0 5 | plugin: 6 | - world 7 | - spell 8 | - gui 9 | - gtk 10 | - photo 11 | - openimg 12 | - settings 13 | - command 14 | - extract 15 | - score 16 | - search 17 | version: '1.0' 18 | author: cobodo 19 | name: Worldon 20 | description: mikutterでMastodonへ接続するworldプラグインです。 21 | -------------------------------------------------------------------------------- /model/application.rb: -------------------------------------------------------------------------------- 1 | module Plugin::Worldon 2 | # https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#application 3 | class Application < Diva::Model 4 | register :worldon_application, name: "Mastodonアプリケーション(Worldon)" 5 | 6 | field.string :name, required: true 7 | field.uri :website 8 | 9 | def inspect 10 | "worldon-application(#{name})" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /model/entity_class.rb: -------------------------------------------------------------------------------- 1 | module Diva::Entity 2 | AnchorLinkEntity = RegexpEntity.filter(/]*>[^<]*<\/a>/, generator: -> h { 3 | a = h[:url] 4 | if h[:url] =~ /]*href="([^"]*)"[^>]*>([^<]*)<\/a>/ 5 | h[:url] = h[:open] = $1 6 | h[:face] = $2 7 | end 8 | h 9 | }) 10 | end 11 | 12 | module Plugin::Worldon 13 | # TODO: タグとかacctとかをいい感じにする 14 | MastodonEntity = Diva::Entity::AnchorLinkEntity 15 | end 16 | -------------------------------------------------------------------------------- /model/mention.rb: -------------------------------------------------------------------------------- 1 | module Plugin::Worldon 2 | # https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#mention 3 | class Mention < Diva::Model 4 | #register :worldon_mention, name: "Mastodonメンション(Worldon)" 5 | 6 | field.uri :url, required: true 7 | field.string :username, required: true 8 | field.string :acct, required: true 9 | field.string :id, required: true 10 | 11 | def inspect 12 | "worldon-mention(#{acct})" 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /model/tag.rb: -------------------------------------------------------------------------------- 1 | module Plugin::Worldon 2 | # https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#tag 3 | class Tag < Diva::Model 4 | register :worldon_tag, name: "Mastodonタグ(Worldon)" 5 | 6 | field.string :name, required: true 7 | field.uri :url, required: true 8 | 9 | def description 10 | "##{name}" 11 | end 12 | 13 | def path 14 | "/#{name}" 15 | end 16 | 17 | def inspect 18 | "worldon-tag(#{name})" 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /score.rb: -------------------------------------------------------------------------------- 1 | Plugin.create(:worldon) do 2 | pm = Plugin::Worldon 3 | 4 | # model#scoreを持っている場合のscore_filter 5 | filter_score_filter do |model, note, yielder| 6 | next [model, note, yielder] unless model == note 7 | next [model, note, yielder] unless (model.is_a?(pm::Status) || model.is_a?(pm::AccountProfile)) 8 | 9 | if model.score.size > 1 || model.score.size == 1 && !model.score[0].is_a?(Plugin::Score::TextNote) 10 | yielder << model.score 11 | end 12 | [model, note, yielder] 13 | end 14 | end 15 | 16 | -------------------------------------------------------------------------------- /icon/libre-gui-lock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /model/emoji.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | module Plugin::Worldon 3 | # https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#emoji 4 | class Emoji < Diva::Model 5 | extend Memoist 6 | #register :worldon_emoji, name: "Mastodon絵文字(Worldon)" 7 | 8 | field.string :shortcode, required: true 9 | field.uri :static_url, required: true 10 | field.uri :url, required: true 11 | 12 | def description 13 | ":#{shortcode}:" 14 | end 15 | 16 | memoize def inline_photo 17 | Enumerator.new{|y| Plugin.filtering(:photo_filter, static_url, y) }.first 18 | end 19 | 20 | def path 21 | "/#{static_url.host}/#{shortcode}" 22 | end 23 | 24 | def inspect 25 | "worldon-emoji(:#{shortcode}:)" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /instance_setting_list.rb: -------------------------------------------------------------------------------- 1 | module Plugin::Worldon 2 | class InstanceSettingList < ::Gtk::TreeView 3 | include Gtk::TreeViewPrettyScroll 4 | COL_DOMAIN = 0 5 | 6 | def initialize() 7 | super() 8 | set_model(::Gtk::ListStore.new(String)) 9 | append_column ::Gtk::TreeViewColumn.new("ドメイン名", ::Gtk::CellRendererText.new, text: COL_DOMAIN) 10 | 11 | Instance.domains.each(&method(:add_record)) 12 | end 13 | 14 | def selected_domain 15 | selected_iter = selection.selected 16 | selected_iter[COL_DOMAIN] if selected_iter 17 | end 18 | 19 | def add_record(domain) 20 | iter = model.append 21 | iter[COL_DOMAIN] = domain 22 | self 23 | end 24 | 25 | def remove_record(domain) 26 | remove_iter = model.to_enum(:each).map{|_,_,iter| iter }.find{|iter| domain == iter[COL_DOMAIN] } 27 | model.remove(remove_iter) if remove_iter 28 | self 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /icon/libre-gui-unlock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 cobodo 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /model/attachment.rb: -------------------------------------------------------------------------------- 1 | module Plugin::Worldon 2 | class AttachmentMeta < Diva::Model 3 | #register :worldon_attachment_meta, name: "Mastodon添付メディア メタ情報(Worldon)" 4 | 5 | field.int :width 6 | field.int :height 7 | field.string :size 8 | field.string :aspect 9 | end 10 | 11 | class AttachmentMetaSet < Diva::Model 12 | #register :worldon_attachment_meta, name: "Mastodon添付メディア メタ情報セット(Worldon)" 13 | 14 | field.has :original, AttachmentMeta 15 | field.has :small, AttachmentMeta 16 | end 17 | 18 | # https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#attachment 19 | class Attachment < Diva::Model 20 | #register :worldon_attachment, name: "Mastodon添付メディア(Worldon)" 21 | 22 | field.string :id, required: true 23 | field.string :type, required: true 24 | field.uri :url 25 | field.uri :remote_url 26 | field.uri :preview_url, required: true 27 | field.uri :text_url 28 | field.string :description 29 | 30 | field.has :meta, AttachmentMetaSet 31 | 32 | def inspect 33 | "worldon-attachment(#{remote_url})" 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | ## Specific to RubyMotion: 17 | .dat* 18 | .repl_history 19 | build/ 20 | *.bridgesupport 21 | build-iPhoneOS/ 22 | build-iPhoneSimulator/ 23 | 24 | ## Specific to RubyMotion (use of CocoaPods): 25 | # 26 | # We recommend against adding the Pods directory to your .gitignore. However 27 | # you should judge for yourself, the pros and cons are mentioned at: 28 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 29 | # 30 | # vendor/Pods/ 31 | 32 | ## Documentation cache and generated files: 33 | /.yardoc/ 34 | /_yardoc/ 35 | /doc/ 36 | /rdoc/ 37 | 38 | ## Environment normalization: 39 | /.bundle/ 40 | /vendor/bundle 41 | /lib/bundler/man/ 42 | 43 | # for a library or gem, you might want to ignore these files since the code is 44 | # intended to run in multiple environments; otherwise, check them in: 45 | # Gemfile.lock 46 | # .ruby-version 47 | # .ruby-gemset 48 | 49 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 50 | .rvmrc 51 | 52 | /json/ 53 | -------------------------------------------------------------------------------- /icon/libre-gui-email.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Worldon 2 | mikutterでMastodonへ接続するWorldプラグインです。 3 | 4 | ## インストール方法 5 | 以下のコマンドを実行します。 6 | 7 | ```shell-session 8 | mkdir -p ~/.mikutter/plugin && git clone git://github.com/cobodo/mikutter-worldon ~/.mikutter/plugin/worldon 9 | ``` 10 | 11 | mikutterを起動して「設定>アカウント情報>追加」もしくは画面左上のアイコンから「Worldを追加」からWorld追加ウィザードを開き、「Mastodonアカウント(Worldon)」を選択して、指示に従ってください。 12 | 13 | WSLなどではリンクからブラウザが開けないと思うので、あらかじめ「設定>表示>URLを開く方法」で適当なブラウザを指定しておいてください。 14 | 15 | Worldが追加されると、ストリーム受信が始まり、データソースが準備されますので、「設定>抽出タブ」で適当なタブを追加し、データソースの一覧から、眺めたいタイムラインを選択してください。 16 | 17 | 投稿欄を右クリックすると、「カスタム投稿」というコマンドがあります。メディア添付時や、公開範囲の変更、ContentWarningの利用時にはこれを使ってください。 18 | 19 | ## 特徴 20 | ### できる🙆 21 | - HTL, FTL, LTL, リストのストリーム受信 22 | - 投稿・返信・ふぁぼ・ブーストの送信 23 | - world対応かつインスタンス越境可能 24 | - 投稿時の画像添付・CW入力・公開範囲変更 25 | - URL・ハッシュタグリンク等の機能 26 | - アカウント登録していないインスタンスの公開TL取得 27 | - 引用tootの展開 28 | - twitterのステータスURLも引用として展開可能 29 | - 返信スレッド表示 30 | - 返信の表示 31 | - 各種汎用イベントの発火 32 | - ミュート設定の反映 33 | - ふぁぼ・ブーストのactivity表示 34 | - 通知サウンド 35 | - [mikutter-subparts-image](https://github.com/moguno/mikutter-subparts-image) による画像表示 36 | - [sub_parts_client](https://github.com/toshia/mikutter-sub-parts-client) によるクライアント表示 37 | - [mikutter_subparts_nsfw](https://github.com/cobodo/mikutter_subparts_nsfw) によるNSFW表示 38 | - カスタム絵文字の表示 39 | - フォロー・フォロー解除・ブロック・ミュート・通報・リストへの所属追加および削除 40 | 41 | ### まだできない🙅 42 | - リストの作成・リネーム・削除 43 | 44 | ### 現状ではできないもの 45 | - mikutter本体の機能拡充が必要 46 | - CW時の本文隠し&表示機構 47 | 48 | ## アイコンについて 49 | 公開範囲の表示に関して、MITライセンスに基づき、[LibreICONS](https://github.com/DiemenDesign/LibreICONS)を利用しています。 50 | ライセンス原文: https://github.com/DiemenDesign/LibreICONS/blob/master/LICENSE 51 | 52 | -------------------------------------------------------------------------------- /extractcondition.rb: -------------------------------------------------------------------------------- 1 | Plugin.create(:worldon) do 2 | cl = Plugin::Worldon::Status 3 | 4 | defextractcondition(:worldon_status, name: "Worldonで受信したトゥート", operator: false, args: 0) do |message: raise| 5 | message.is_a?(cl) 6 | end 7 | defextractcondition(:worldon_domain, name: "ドメイン(Worldon)", operator: true, args: 1, sexp: MIKU.parse("`(,compare (host (uri message)) ,(car args))")) 8 | defextractcondition(:worldon_spoiler_text, name: "CWテキスト(Worldon)", operator: true, args: 1) do |arg, message: raise, operator: raise, &compare| 9 | message.is_a?(cl) && compare.(message.spoiler_text, arg) 10 | end 11 | defextractcondition(:worldon_visibility, name: "公開範囲(Worldon)", operator: true, args: 1) do |arg, message: raise, operator: raise, &compare| 12 | message.is_a?(cl) && compare.(message.visibility, arg) 13 | end 14 | defextractcondition(:worldon_include_emoji, name: "カスタム絵文字を含む(Worldon)", operator: false, args: 0) do |message: raise| 15 | message.is_a?(cl) && message.emojis.to_a.any? 16 | end 17 | defextractcondition(:worldon_emoji, name: "カスタム絵文字(Worldon)", operator: true, args: 1) do |arg, message: raise, operator: raise, &compare| 18 | message.is_a?(cl) && compare.(message.emojis.to_a.map{|emoji| emoji.shortcode }.join(' '), arg) 19 | end 20 | defextractcondition(:worldon_tag, name: "ハッシュタグ(Worldon)", operator: true, args: 1) do |arg, message: raise, operator: raise, &compare| 21 | message.is_a?(cl) && compare.(message.tags.to_a.map{|tag| tag.name }.join(' '), arg.downcase) 22 | end 23 | defextractcondition(:worldon_bio, name: "プロフィール(Worldon)", operator: true, args: 1) do |arg, message: raise, operator: raise, &compare| 24 | message.is_a?(cl) && compare.(message.account.note, arg) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /model/account_profile.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | require 'cgi' 3 | 4 | module Plugin::Worldon 5 | class AccountProfile < Diva::Model 6 | extend Memoist 7 | include Diva::Model::MessageMixin 8 | 9 | register :worldon_account_profile, name: "Mastodonアカウントプロフィール(Worldon)", timeline: true, myself: true 10 | 11 | field.has :account, Account, required: true 12 | alias :user :account 13 | 14 | attr_reader :description 15 | attr_reader :score 16 | 17 | def initialize(hash) 18 | super hash 19 | 20 | @description, @score = PM::Parser.dictate_score(description_html, emojis: account.emojis) 21 | end 22 | 23 | def created 24 | account.created_at 25 | end 26 | 27 | def title 28 | account.display_name 29 | end 30 | 31 | memoize def description_html 32 | fields = "" 33 | if account.fields.size > 0 34 | fields = account.fields.map { |f| 35 | "#{CGI.escapeHTML(f.name)}:#{f.value}" 36 | }.join("
") 37 | fields = "

#{fields}

" 38 | end 39 | 40 | paragraphs = [ 41 | "

#{CGI.escapeHTML(account.display_name)}
#{account.acct}#{account.bot ? "
Bot" : ""}

", 42 | "#{account.note}" 43 | ] 44 | paragraphs.push fields unless fields.empty? 45 | paragraphs.push "

#{account.statuses_count} トゥート
#{account.following_count} フォロー
#{account.followers_count} フォロワー

" 46 | paragraphs.join('') 47 | end 48 | 49 | def perma_link 50 | account.url 51 | end 52 | 53 | def uri 54 | account.url 55 | end 56 | 57 | def from_me_world 58 | world = Plugin.filtering(:world_current, nil).first 59 | return nil if (!world.respond_to?(:account) || !world.account.respond_to?(:acct)) 60 | return nil if account.acct != world.account.acct 61 | world 62 | end 63 | 64 | def from_me?(world = nil) 65 | if world 66 | if world.is_a? Plugin::Worldon::World 67 | return account.acct == world.account.acct 68 | else 69 | return false 70 | end 71 | end 72 | !!from_me_world 73 | end 74 | end 75 | end 76 | 77 | -------------------------------------------------------------------------------- /rest.rb: -------------------------------------------------------------------------------- 1 | Plugin.create(:worldon) do 2 | pm = Plugin::Worldon 3 | settings = {} 4 | 5 | on_worldon_request_rest do |slug| 6 | Thread.new { 7 | notice "Worldon: rest request for #{slug}" 8 | domain = settings[slug][:domain] 9 | path = settings[slug][:path] 10 | token = settings[slug][:token] 11 | params = settings[slug][:params] 12 | 13 | if !settings[slug][:last_id].nil? 14 | params[:since_id] = settings[slug][:last_id] 15 | params.delete(:limit) 16 | end 17 | 18 | tl = [] 19 | begin 20 | hashes = pm::API.call(:get, domain, path, token, **params) 21 | settings[slug][:last_time] = Time.now.to_i 22 | next if hashes.nil? 23 | arr = hashes.value 24 | ids = arr.map{|hash| hash[:id].to_i } 25 | tl = pm::Status.build(domain, arr).concat(tl) 26 | 27 | notice "Worldon: REST取得数: #{ids.size} for #{slug}" 28 | if ids.size > 0 29 | settings[slug][:last_id] = params[:since_id] = ids.max 30 | # 2回目以降、limit=20いっぱいまで取れてしまった場合は続きの取得を行なう。 31 | if (!settings[slug][:last_id].nil? && ids.size == 20) 32 | notice "Worldon: 継ぎ足しREST #{slug}" 33 | end 34 | end 35 | end while (!settings[slug][:last_id].nil? && ids.size == 20) 36 | if domain.nil? && Mopt.error_level >= 2 # warn 37 | puts "on_worldon_start_stream domain is null #{type} #{slug} #{token.to_s} #{list_id.to_s}" 38 | pp tl.select{|status| status.domain.nil? } 39 | $stdout.flush 40 | end 41 | Plugin.call :extract_receive_message, slug, tl if !tl.empty? 42 | 43 | reblogs = tl.select{|status| status.reblog? } 44 | Plugin.call(:retweet, reblogs) if !reblogs.empty? 45 | } 46 | end 47 | 48 | on_worldon_init_polling do |slug, domain, path, token, params| 49 | settings[slug] = { 50 | last_time: 0, 51 | last_id: nil, 52 | domain: domain, 53 | path: path, 54 | token: token, 55 | params: params, 56 | } 57 | 58 | Plugin.call(:worldon_request_rest, slug) 59 | end 60 | 61 | on_worldon_start_stream do |domain, type, slug, world, list_id| 62 | Thread.new { 63 | sleep(rand(10)) 64 | 65 | # 直近の分を取得 66 | token = nil 67 | if world.is_a? pm::World 68 | token = world.access_token 69 | end 70 | params = { limit: 40 } 71 | path_base = '/api/v1/timelines/' 72 | case type 73 | when 'user' 74 | path = path_base + 'home' 75 | when 'public' 76 | path = path_base + 'public' 77 | when 'public:media' 78 | path = path_base + 'public' 79 | params[:only_media] = 1 80 | when 'public:local' 81 | path = path_base + 'public' 82 | params[:local] = 1 83 | when 'public:local:media' 84 | path = path_base + 'public' 85 | params[:local] = 1 86 | params[:only_media] = 1 87 | when 'list' 88 | path = path_base + 'list/' + list_id.to_s 89 | when 'direct' 90 | path = path_base + 'direct' 91 | end 92 | 93 | Plugin.call(:worldon_init_polling, slug, domain, path, token, params) 94 | } 95 | end 96 | 97 | pinger = Proc.new do 98 | if !UserConfig[:worldon_enable_streaming] 99 | now = Time.now.to_i 100 | settings.each do |slug, setting| 101 | if (now - settings[slug][:last_time]) >= 60 * UserConfig[:worldon_rest_interval] 102 | Plugin.call(:worldon_request_rest, slug) 103 | end 104 | end 105 | end 106 | Reserver.new(60, thread: SerialThread, &pinger) 107 | end 108 | 109 | Reserver.new(60, thread: SerialThread, &pinger) 110 | end 111 | -------------------------------------------------------------------------------- /setting.rb: -------------------------------------------------------------------------------- 1 | require_relative 'instance_setting_list' 2 | 3 | Plugin.create(:worldon) do 4 | pm = Plugin::Worldon 5 | 6 | # 設定の初期化 7 | defaults = { 8 | worldon_enable_streaming: true, 9 | worldon_rest_interval: UserConfig[:retrieve_interval_friendtl], 10 | worldon_show_subparts_visibility: true, 11 | worldon_show_subparts_bot: true, 12 | worldon_show_subparts_pin: true, 13 | worldon_instances: Hash.new, 14 | } 15 | defaults.each do |key, value| 16 | if UserConfig[key].nil? 17 | UserConfig[key] = value 18 | end 19 | end 20 | 21 | instance_config = at(:instances) 22 | if instance_config 23 | UserConfig[:worldon_instances] = instance_config.merge(UserConfig[:worldon_instances]) 24 | store(:instances, nil) 25 | end 26 | 27 | 28 | # 追加 29 | on_worldon_instances_open_create_dialog do 30 | dialog "インスタンス設定の追加" do 31 | error_msg = nil 32 | while true 33 | if error_msg 34 | label error_msg 35 | end 36 | input "インスタンスのドメイン", :domain 37 | result = await_input 38 | if result[:domain].empty? 39 | error_msg = "ドメイン名を入力してください。" 40 | next 41 | end 42 | if UserConfig[:worldon_instances].has_key?(result[:domain]) 43 | error_msg = "既に登録済みのドメインです。入力し直してください。" 44 | next 45 | end 46 | instance, = Plugin.filtering(:worldon_add_instance, result[:domain]) 47 | if instance.nil? 48 | error_msg = "接続に失敗しました。もう一度確認してください。" 49 | next 50 | end 51 | 52 | break 53 | end 54 | domain = result[:domain] 55 | label "#{domain} インスタンスを追加しました" 56 | Plugin.call(:worldon_restart_instance_stream, domain) 57 | Plugin.call(:worldon_instance_created, domain) 58 | end 59 | end 60 | 61 | # 編集 62 | on_worldon_instances_open_edit_dialog do |domain| 63 | config = UserConfig[:worldon_instances][domain] 64 | 65 | dialog "インスタンス設定の編集" do 66 | label "インスタンスのドメイン: #{domain}" 67 | end.next do |result| 68 | Plugin.call(:worldon_update_instance, result.domain) 69 | end 70 | end 71 | 72 | # 削除 73 | on_worldon_instances_delete_with_confirm do |domain| 74 | next if UserConfig[:worldon_instances][domain].nil? 75 | dialog "インスタンス設定の削除" do 76 | label "インスタンス #{domain} を削除しますか?" 77 | end.next { 78 | Plugin.call(:worldon_delete_instance, domain) 79 | } 80 | end 81 | 82 | # 設定 83 | settings "Worldon" do 84 | settings "表示" do 85 | boolean 'botアカウントにアイコンを表示する', :worldon_show_subparts_bot 86 | boolean 'ピン留めトゥートにアイコンを表示する', :worldon_show_subparts_pin 87 | boolean 'トゥートに公開範囲を表示する', :worldon_show_subparts_visibility 88 | end 89 | 90 | settings "接続" do 91 | boolean 'ストリーミング接続する', :worldon_enable_streaming 92 | adjustment '接続間隔(分)', :worldon_rest_interval, 1, 60*24 93 | end 94 | 95 | settings "公開タイムライン" do 96 | treeview = Plugin::Worldon::InstanceSettingList.new 97 | btn_add = Gtk::Button.new(Gtk::Stock::ADD) 98 | btn_delete = Gtk::Button.new(Gtk::Stock::DELETE) 99 | btn_add.ssc(:clicked) do 100 | Plugin.call(:worldon_instances_open_create_dialog) 101 | true 102 | end 103 | btn_delete.ssc(:clicked) do 104 | domain = treeview.selected_domain 105 | if domain 106 | Plugin.call(:worldon_instances_delete_with_confirm, domain) end 107 | true 108 | end 109 | scrollbar = ::Gtk::VScrollbar.new(treeview.vadjustment) 110 | pack_start( 111 | Gtk::HBox.new(false, 4). 112 | add(treeview). 113 | closeup(scrollbar). 114 | closeup( 115 | Gtk::VBox.new. 116 | closeup(btn_add). 117 | closeup(btn_delete))) 118 | Plugin.create :worldon do 119 | pm = Plugin::Worldon 120 | 121 | add_tab_observer = on_worldon_instance_created(&treeview.method(:add_record)) 122 | delete_tab_observer = on_worldon_delete_instance(&treeview.method(:remove_record)) 123 | treeview.ssc(:destroy) do 124 | detach add_tab_observer 125 | detach delete_tab_observer 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /model/account.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | module Plugin::Worldon 3 | class AccountSource < Diva::Model 4 | #register :worldon_account_source, name: "Mastodonアカウント追加情報(Worldon)" 5 | 6 | field.string :privacy 7 | field.bool :sensitive 8 | field.string :note 9 | end 10 | 11 | class AccountField < Diva::Model 12 | field.string :name 13 | field.string :value 14 | 15 | def inspect 16 | "#{name}: #{value}" 17 | end 18 | end 19 | 20 | # https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status 21 | class Account < Diva::Model 22 | include Diva::Model::UserMixin 23 | 24 | register :worldon_account, name: "Mastodonアカウント(Worldon)" 25 | 26 | field.string :id, required: true 27 | field.string :username, required: true 28 | field.string :acct, required: true 29 | field.string :display_name, required: true 30 | field.bool :locked, required: true 31 | field.time :created_at, required: true 32 | field.int :followers_count, required: true 33 | field.int :following_count, required: true 34 | field.int :statuses_count, required: true 35 | field.string :note, required: true 36 | field.uri :url, required: true 37 | field.uri :avatar, required: true 38 | field.uri :avatar_static, required: true 39 | field.uri :header, required: true 40 | field.uri :header_static, required: true 41 | field.has :emojis, [Emoji] 42 | field.has :moved, Account 43 | field.has :fields, [AccountField] 44 | field.bool :bot 45 | field.has :source, AccountSource 46 | 47 | alias :perma_link :url 48 | alias :uri :url 49 | alias :idname :acct 50 | alias :name :display_name 51 | 52 | @@account_storage = WeakStorage.new(String, Account) 53 | 54 | ACCOUNT_URI_RE = %r!\Ahttps://(?[^/]+)/@(?\w{1,30})\z! 55 | 56 | handle ACCOUNT_URI_RE do |uri| 57 | m = ACCOUNT_URI_RE.match(uri.to_s) 58 | acct = "#{m["acct"]}@#{m["domain"]}" 59 | account = Account.findbyacct(acct) 60 | next account if account 61 | 62 | Thread.new { 63 | Account.fetch(acct) 64 | } 65 | end 66 | 67 | def self.regularize_acct_by_domain(domain, acct) 68 | if acct.index('@').nil? 69 | acct = acct + '@' + domain 70 | end 71 | acct 72 | end 73 | 74 | def self.regularize_acct(hash) 75 | domain = Diva::URI.new(hash[:url]).host 76 | acct = hash[:acct] 77 | hash[:acct] = self.regularize_acct_by_domain(domain, acct) 78 | hash 79 | end 80 | 81 | def self.domain(url) 82 | Diva::URI.new(url.to_s).host 83 | end 84 | 85 | def self.findbyacct(acct) 86 | @@account_storage[acct] 87 | end 88 | 89 | def self.fetch(acct) 90 | world, = Plugin.filtering(:worldon_current, nil) 91 | resp = Plugin::Worldon::API.call(:get, world.domain, '/api/v1/search', world.access_token, q: acct, resolve: true) 92 | hash = resp[:accounts].select{|account| account[:acct] === acct }.first 93 | if hash 94 | Account.new hash 95 | end 96 | end 97 | 98 | def domain 99 | self.class.domain(url) 100 | end 101 | 102 | def initialize(hash) 103 | if hash[:created_at].is_a? String 104 | hash[:created_at] = Time.parse(hash[:created_at]).localtime 105 | end 106 | hash = self.class.regularize_acct(hash) 107 | 108 | # activity対策 109 | hash[:idname] = hash[:acct] 110 | 111 | hash[:name] = hash[:display_name] 112 | 113 | super hash 114 | 115 | @@account_storage[hash[:acct]] = self 116 | 117 | self 118 | end 119 | 120 | def inspect 121 | "worldon-account(#{acct})" 122 | end 123 | 124 | def title 125 | "#{acct}(#{display_name})" 126 | end 127 | 128 | def description 129 | "@#{acct}" 130 | end 131 | 132 | def icon 133 | Enumerator.new{|y| 134 | Plugin.filtering(:photo_filter, avatar_static, y) 135 | }.lazy.map{|photo| 136 | Plugin.filtering(:miracle_icon_filter, photo)[0] 137 | }.first 138 | end 139 | 140 | def me? 141 | world = Plugin.filtering(:world_current, nil).first 142 | world.respond_to?(:account) && world.account.respond_to?(:acct) && world.account.acct == acct 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /model/instance.rb: -------------------------------------------------------------------------------- 1 | module Plugin::Worldon 2 | class Instance < Diva::Model 3 | register :worldon_instance, name: "Mastodonインスタンス(Worldon)" 4 | 5 | field.string :domain, required: true 6 | field.string :client_key, required: true 7 | field.string :client_secret, required: true 8 | field.bool :retrieve, required: true 9 | 10 | class << self 11 | def datasource_slug(domain, type) 12 | case type 13 | when :local 14 | # ローカルTL 15 | "worldon-#{domain}-local".to_sym 16 | when :local_media 17 | # ローカルメディアTL 18 | "worldon-#{domain}-local-media".to_sym 19 | when :federated 20 | # 連合TL 21 | "worldon-#{domain}-federated".to_sym 22 | when :federated_media 23 | # 連合メディアTL 24 | "worldon-#{domain}-federated-media".to_sym 25 | end 26 | end 27 | 28 | def add_datasources(domain) 29 | Plugin[:worldon].filter_extract_datasources do |dss| 30 | datasources = { 31 | datasource_slug(domain, :local) => "Mastodon公開タイムライン(Worldon)/#{domain} ローカル", 32 | datasource_slug(domain, :local_media) => "Mastodon公開タイムライン(Worldon)/#{domain} ローカル(メディア)", 33 | datasource_slug(domain, :federated) => "Mastodon公開タイムライン(Worldon)/#{domain} 連合", 34 | datasource_slug(domain, :federated_media) => "Mastodon公開タイムライン(Worldon)/#{domain} 連合(メディア)", 35 | } 36 | [datasources.merge(dss)] 37 | end 38 | end 39 | 40 | def remove_datasources(domain) 41 | Plugin[:worldon].filter_extract_datasources do |datasources| 42 | datasources.delete datasource_slug(domain, :local) 43 | datasources.delete datasource_slug(domain, :local_media) 44 | datasources.delete datasource_slug(domain, :federated) 45 | datasources.delete datasource_slug(domain, :federated_media) 46 | [datasources] 47 | end 48 | end 49 | 50 | def add(domain, retrieve = true) 51 | return nil if UserConfig[:worldon_instances].has_key?(domain) 52 | 53 | resp = Plugin::Worldon::API.call(:post, domain, '/api/v1/apps', 54 | client_name: Plugin::Worldon::CLIENT_NAME, 55 | redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', 56 | scopes: 'read write follow', 57 | website: Plugin::Worldon::WEB_SITE 58 | ) 59 | return nil if resp.nil? 60 | add_datasources(domain) 61 | 62 | self.new( 63 | domain: domain, 64 | client_key: resp[:client_id], 65 | client_secret: resp[:client_secret], 66 | retrieve: retrieve, 67 | ).store 68 | end 69 | 70 | def load(domain) 71 | if UserConfig[:worldon_instances][domain].nil? 72 | nil 73 | else 74 | self.new( 75 | domain: domain, 76 | client_key: UserConfig[:worldon_instances][domain][:client_key], 77 | client_secret: UserConfig[:worldon_instances][domain][:client_secret], 78 | retrieve: UserConfig[:worldon_instances][domain][:retrieve], 79 | ) 80 | end 81 | end 82 | 83 | def remove(domain) 84 | remove_datasources(domain) 85 | UserConfig[:worldon_instances].delete(domain) 86 | end 87 | 88 | def domains 89 | UserConfig[:worldon_instances].keys.dup 90 | end 91 | 92 | def settings 93 | UserConfig[:worldon_instances].map do |domain, value| 94 | { domain: domain, retrieve: value[:retrieve] } 95 | end 96 | end 97 | end # class instance 98 | 99 | def store 100 | configs = UserConfig[:worldon_instances].dup 101 | configs[domain] = { client_key: client_key, client_secret: client_secret, retrieve: retrieve } 102 | UserConfig[:worldon_instances] = configs 103 | self 104 | end 105 | 106 | def authorize_url 107 | params = URI.encode_www_form({ 108 | scope: 'read write follow', 109 | response_type: 'code', 110 | redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', 111 | client_id: client_key 112 | }) 113 | 'https://' + domain + '/oauth/authorize?' + params 114 | end 115 | 116 | def inspect 117 | "worldon-instance(#{domain})" 118 | end 119 | 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /subparts_status_info.rb: -------------------------------------------------------------------------------- 1 | class Gdk::SubPartsWorldonStatusInfo < Gdk::SubParts 2 | register 3 | 4 | def get_photo(filename) 5 | return nil if filename.nil? 6 | path = Pathname(__dir__) / 'icon' / filename 7 | uri = Diva::URI.new('file://' + path.to_s) 8 | Plugin.filtering(:photo_filter, uri, [])[1].first 9 | end 10 | 11 | def filename(visibility) 12 | # アイコン素材取得元→ http://icooon-mono.com/license/ 13 | case visibility 14 | when 'unlisted' 15 | 'unlisted.png' 16 | when 'private' 17 | 'private.png' 18 | when 'direct' 19 | 'direct.png' 20 | else 21 | nil 22 | end 23 | end 24 | 25 | def icon_pixbuf 26 | return nil if !helper.message.respond_to?(:visibility) 27 | photo = get_photo(filename(helper.message.visibility)) 28 | photo&.pixbuf(width: @icon_size, height: @icon_size) 29 | end 30 | 31 | def show_icon? 32 | return true if (UserConfig[:worldon_show_subparts_bot] && helper.message.user.respond_to?(:bot) && helper.message.user.bot) 33 | return true if (UserConfig[:worldon_show_subparts_pin] && helper.message.respond_to?(:pinned?) && helper.message.pinned?) 34 | return true if (UserConfig[:worldon_show_subparts_visibility] && helper.message.respond_to?(:visibility) && filename(helper.message.visibility)) 35 | false 36 | end 37 | 38 | def visibility_text(visibility) 39 | case visibility 40 | when 'unlisted' 41 | '未収載' 42 | when 'private' 43 | '非公開' 44 | when 'direct' 45 | 'ダイレクト' 46 | else 47 | '' 48 | end 49 | end 50 | 51 | def initialize(*args) 52 | super 53 | 54 | @margin = 2 55 | @icon_size = 20 56 | end 57 | 58 | def render(context) 59 | if helper.visible? && show_icon? 60 | if helper.message.user.respond_to?(:bot) && helper.message.user.bot 61 | bot_pixbuf = get_photo('bot.png')&.pixbuf(width: @icon_size, height: @icon_size) 62 | end 63 | if helper.message.respond_to?(:pinned?) && helper.message.pinned? 64 | pin_pixbuf = get_photo('pin.png')&.pixbuf(width: @icon_size, height: @icon_size) 65 | end 66 | visibility_pixbuf = icon_pixbuf 67 | context.save do 68 | context.translate(0, @margin) 69 | 70 | if UserConfig[:worldon_show_subparts_bot] && bot_pixbuf 71 | context.translate(@margin, 0) 72 | context.set_source_pixbuf(bot_pixbuf) 73 | context.paint 74 | 75 | context.translate(@icon_size + @margin, 0) 76 | layout = context.create_pango_layout 77 | layout.font_description = Pango::FontDescription.new(UserConfig[:mumble_basic_font]) 78 | layout.text = "bot" 79 | bot_text_width = layout.extents[1].width / Pango::SCALE 80 | context.set_source_rgb(*(UserConfig[:mumble_basic_color] || [0,0,0]).map{ |c| c.to_f / 65536 }) 81 | context.show_pango_layout(layout) 82 | context.translate(bot_text_width, 0) 83 | end 84 | 85 | if UserConfig[:worldon_show_subparts_pin] && pin_pixbuf 86 | context.translate(@margin, 0) 87 | context.set_source_pixbuf(pin_pixbuf) 88 | context.paint 89 | 90 | context.translate(@icon_size + @margin, 0) 91 | layout = context.create_pango_layout 92 | layout.font_description = Pango::FontDescription.new(UserConfig[:mumble_basic_font]) 93 | layout.text = "ピン留め" 94 | pin_text_width = layout.extents[1].width / Pango::SCALE 95 | context.set_source_rgb(*(UserConfig[:mumble_basic_color] || [0,0,0]).map{ |c| c.to_f / 65536 }) 96 | context.show_pango_layout(layout) 97 | context.translate(pin_text_width, 0) 98 | end 99 | 100 | if UserConfig[:worldon_show_subparts_visibility] && visibility_pixbuf 101 | context.translate(@margin, 0) 102 | 103 | context.set_source_pixbuf(visibility_pixbuf) 104 | context.paint 105 | 106 | context.translate(@icon_size + @margin, 0) 107 | layout = context.create_pango_layout 108 | layout.font_description = Pango::FontDescription.new(UserConfig[:mumble_basic_font]) 109 | layout.text = visibility_text(helper.message.visibility) 110 | context.set_source_rgb(*(UserConfig[:mumble_basic_color] || [0,0,0]).map{ |c| c.to_f / 65536 }) 111 | context.show_pango_layout(layout) 112 | end 113 | end 114 | end 115 | end 116 | 117 | def height 118 | @height ||= show_icon? ? @icon_size + 2 * @margin : 0 119 | end 120 | end 121 | 122 | -------------------------------------------------------------------------------- /parser.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' # unescapeHTML 2 | 3 | module Plugin::Worldon::Parser 4 | def self.dehtmlize(html) 5 | result = html 6 | .gsub(%r!

!) { "\n\n" } 7 | .gsub(%r!([^<]*)!) {|s| $1 + "..." } 8 | .gsub(%r!^

|

||]*>!, '') 9 | .gsub(/]*>|

/) { "\n" } 10 | .gsub(/'/) { "'" } 11 | result 12 | end 13 | 14 | def self.dictate_score(html, mentions: [], emojis: [], media_attachments: []) 15 | desc = dehtmlize(html) 16 | 17 | score = [] 18 | 19 | # リンク処理 20 | # TODO: user_detail_viewを作ったらacctをAccount Modelにする 21 | pos = 0 22 | anchor_re = %r|[^>]*) href="(?[^"]*)"(?[^>]*)>(?[^<]*)| 23 | urls = [] 24 | while m = anchor_re.match(desc, pos) 25 | anchor_begin = m.begin(0) 26 | anchor_end = m.end(0) 27 | if pos < anchor_begin 28 | score << Plugin::Score::TextNote.new(description: CGI.unescapeHTML(desc[pos...anchor_begin])) 29 | end 30 | url = Diva::URI.new(CGI.unescapeHTML(m["url"])) 31 | if m["text"][0] == '#' || (score.last.to_s[-1] == '#') 32 | score << Plugin::Worldon::Tag.new(name: CGI.unescapeHTML(m["text"]).sub(/\A#/, '')) 33 | else 34 | account = nil 35 | if mentions.any? { |mention| mention.url == url } 36 | mention = mentions.lazy.select { |mention| mention.url == url }.first 37 | acct = Plugin::Worldon::Account.regularize_acct_by_domain(mention.url.host, mention.acct) 38 | account = Plugin::Worldon::Account.findbyacct(acct) 39 | end 40 | if account 41 | score << account 42 | else 43 | link_hash = { 44 | description: CGI.unescapeHTML(m["text"]), 45 | uri: url, 46 | worldon_link_attr: Hash.new, 47 | } 48 | attrs = m["attr1"] + m["attr2"] 49 | attr_pos = 0 50 | attr_re = %r| (?[^=]+)="(?[^"]*)"| 51 | while m2 = attr_re.match(attrs, attr_pos) 52 | attr_name = m2["name"].to_sym 53 | attr_value = m2["value"] 54 | if [:class, :rel].include? attr_name 55 | link_hash[:worldon_link_attr][attr_name] = attr_value.split(' ') 56 | else 57 | link_hash[:worldon_link_attr][attr_name] = attr_value 58 | end 59 | attr_pos = m2.end(0) 60 | end 61 | score << Plugin::Score::HyperLinkNote.new(link_hash) 62 | end 63 | end 64 | urls << url 65 | pos = anchor_end 66 | end 67 | if pos < desc.size 68 | score << Plugin::Score::TextNote.new(description: CGI.unescapeHTML(desc[pos...desc.size])) 69 | end 70 | 71 | # 添付ファイル用のwork around 72 | # TODO: mikutter本体側が添付ファイル用のNoteを用意したらそちらに移行する 73 | if media_attachments.size > 0 74 | media_attachments 75 | .select {|attachment| 76 | !urls.include?(attachment.url.to_s) && !urls.include?(attachment.text_url.to_s) 77 | } 78 | .each {|attachment| 79 | score << Plugin::Score::TextNote.new(description: "\n") 80 | 81 | description = attachment.text_url 82 | if !description 83 | description = attachment.url 84 | end 85 | score << Plugin::Score::HyperLinkNote.new(description: description, uri: attachment.url) 86 | } 87 | end 88 | 89 | score = score.flat_map do |note| 90 | if !note.is_a?(Plugin::Score::TextNote) 91 | [note] 92 | else 93 | emoji_score = Enumerator.new{|y| 94 | dictate_emoji(note.description, emojis, y) 95 | }.first.to_a 96 | if emoji_score.size > 0 97 | emoji_score 98 | else 99 | [note] 100 | end 101 | end 102 | end 103 | 104 | description = score.inject('') do |acc, note| 105 | desc = note.is_a?(Plugin::Score::HyperLinkNote) ? note.uri.to_s : note.description 106 | acc + desc 107 | end 108 | 109 | [description, score] 110 | end 111 | 112 | # 与えられたテキスト断片に対し、emojisでEmojiを置換するscoreを返します。 113 | def self.dictate_emoji(text, emojis, yielder) 114 | score = emojis.inject(Array(text)){ |fragments, emoji| 115 | shortcode = ":#{emoji.shortcode}:" 116 | fragments.flat_map{|fragment| 117 | if fragment.is_a?(String) 118 | if fragment === shortcode 119 | [emoji] 120 | else 121 | sub_fragments = fragment.split(shortcode).flat_map{|str| 122 | [str, emoji] 123 | } 124 | sub_fragments.pop unless fragment.end_with?(shortcode) 125 | sub_fragments 126 | end 127 | else 128 | [fragment] 129 | end 130 | } 131 | }.map{|chunk| 132 | if chunk.is_a?(String) 133 | Plugin::Score::TextNote.new(description: chunk) 134 | else 135 | chunk 136 | end 137 | } 138 | 139 | if (score.size > 1 || score.size == 1 && !score[0].is_a?(Plugin::Score::TextNote)) 140 | yielder << score 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /sse_client.rb: -------------------------------------------------------------------------------- 1 | require 'httpclient' 2 | 3 | module Plugin::SseClient 4 | class Parser 5 | attr_reader :buffer 6 | 7 | def initialize(plugin, slug) 8 | @plugin = plugin 9 | @slug = slug 10 | @buffer = '' 11 | @records = [] 12 | @event = @data = nil 13 | end 14 | 15 | def <<(str) 16 | @buffer += str 17 | consume 18 | self 19 | end 20 | 21 | def consume 22 | # 改行で分割 23 | lines = @buffer.split("\n", -1) 24 | @buffer = lines.pop # 余りを次回に持ち越し 25 | 26 | # SSEのメッセージパース 27 | records = lines 28 | .select{|l| !l.start_with?(":") } # コメント除去 29 | .map{|l| 30 | key, value = l.split(": ", 2) 31 | { key: key, value: value } 32 | } 33 | .select{|r| 34 | ['event', 'data', 'id', 'retry', nil].include?(r[:key]) 35 | # これら以外のフィールドは無視する(nilは空行検出のため) 36 | # cf. https://developer.mozilla.org/ja/docs/Server-sent_events/Using_server-sent_events#Event_stream_format 37 | } 38 | @records.concat(records) 39 | 40 | last_type = nil 41 | while r = @records.shift 42 | if last_type == 'data' && r[:key] != 'data' 43 | if @event.nil? 44 | @event = '' 45 | end 46 | Plugin.call(:sse_message_type_event, @slug, @event, @data) 47 | Plugin.call(:"sse_on_#{@event}", @slug, @data) # 利便性のため 48 | @event = @data = nil # 一応リセット 49 | end 50 | 51 | case r[:key] 52 | when nil 53 | # 空行→次の処理単位へ移動 54 | @event = @data = nil 55 | last_type = nil 56 | when 'event' 57 | # イベントタイプ指定 58 | @event = r[:value] 59 | last_type = 'event' 60 | when 'data' 61 | # データ本体 62 | if @data.empty? 63 | @data = '' 64 | else 65 | @data += "\n" 66 | end 67 | @data += r[:value] 68 | last_type = 'data' 69 | when 'id' 70 | # EventSource オブジェクトの last event ID の値に設定する、イベント ID です。 71 | Plugin.call(:sse_message_type_id, @slug, id) 72 | @event = @data = nil # 一応リセット 73 | last_type = 'id' 74 | when 'retry' 75 | # イベントの送信を試みるときに使用する reconnection time です。[What code handles this?] 76 | # これは整数値であることが必要で、reconnection time をミリ秒単位で指定します。 77 | # 整数値ではない値が指定されると、このフィールドは無視されます。 78 | # 79 | # [What code handles this?]じゃねんじゃw 80 | if r[:value] =~ /\A-?(0|[1-9][0-9]*)\Z/ 81 | Plugin.call(:sse_message_type_retry, @slug, r[:value].to_i) 82 | end 83 | @event = @data = nil # 一応リセット 84 | last_type = 'retry' 85 | else 86 | end 87 | end 88 | end 89 | end 90 | end 91 | 92 | Plugin.create(:sse_client) do 93 | pm = Plugin::Worldon 94 | 95 | connections = {} 96 | mutex = Thread::Mutex.new 97 | 98 | on_sse_create do |slug, method, uri, headers = {}, params = {}, **opts| 99 | begin 100 | mutex.synchronize { 101 | if connections.has_key? slug 102 | warn "\n!!!! sse_client streaming duplicate !!!!\n" 103 | thread = connections[slug][:thread] 104 | connections.delete(slug) 105 | thread.kill 106 | end 107 | } 108 | 109 | conv = [] 110 | params.each do |key, val| 111 | if val.is_a? Array 112 | val.each do |v| 113 | conv << [key.to_s + '[]', v] 114 | end 115 | else 116 | conv << [key.to_s, val] 117 | end 118 | end 119 | 120 | query = {} 121 | body = {} 122 | 123 | case method 124 | when :get 125 | query = conv 126 | when :post 127 | body = conv 128 | end 129 | 130 | Plugin.call(:sse_connection_opening, slug) 131 | client = HTTPClient.new 132 | 133 | thread = Thread.new { 134 | begin 135 | parser = Plugin::SseClient::Parser.new(self, slug) 136 | response = client.request(method, uri.to_s, query, body, headers) do |fragment| 137 | parser << fragment 138 | end 139 | 140 | case response.status 141 | when 200 142 | else 143 | Plugin.call(:sse_connection_failure, slug, response) 144 | error "ServerSentEvents connection failure" 145 | pp response if Mopt.error_level >= 1 146 | $stdout.flush 147 | next 148 | end 149 | 150 | Plugin.call(:sse_connection_closed, slug) 151 | 152 | rescue => e 153 | Plugin.call(:sse_connection_error, slug, e) 154 | error "ServerSentEvents connection error" 155 | pp e if Mopt.error_level >= 1 156 | $stdout.flush 157 | next 158 | end 159 | } 160 | mutex.synchronize { 161 | connections[slug] = { 162 | method: method, 163 | uri: uri, 164 | headers: headers, 165 | params: params, 166 | opts: opts, 167 | thread: thread, 168 | } 169 | } 170 | 171 | rescue => e 172 | Plugin.call(:sse_connection_error, slug, e) 173 | error "ServerSentEvents connection error" 174 | pp e if Mopt.error_level >= 1 175 | $stdout.flush 176 | nil 177 | end 178 | end 179 | 180 | on_sse_kill_connection do |slug| 181 | thread = nil 182 | mutex.synchronize { 183 | if connections.has_key? slug 184 | thread = connections[slug][:thread] 185 | connections.delete(slug) 186 | end 187 | } 188 | if thread 189 | thread.kill 190 | end 191 | end 192 | 193 | on_sse_kill_all do |event_sym| 194 | threads = [] 195 | mutex.synchronize { 196 | connections.each do |slug, hash| 197 | threads << hash[:thread] 198 | end 199 | connections = {} 200 | } 201 | threads.each do |thread| 202 | thread.kill 203 | end 204 | 205 | Plugin.call(event_sym) if event_sym 206 | end 207 | 208 | filter_sse_connection do |slug| 209 | [connections[slug]] 210 | end 211 | 212 | filter_sse_connection_all do |_| 213 | [connections] 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /api.rb: -------------------------------------------------------------------------------- 1 | require 'httpclient' 2 | require 'json' 3 | require 'stringio' 4 | 5 | module Plugin::Worldon 6 | class APIResult 7 | attr_reader :value 8 | attr_reader :header 9 | 10 | def initialize(value, header = nil) 11 | @value = value 12 | @header = header 13 | end 14 | 15 | def [](idx) 16 | @value[idx] 17 | end 18 | 19 | def []=(idx, val) 20 | @value[idx] = val 21 | end 22 | 23 | def to_h 24 | @value.to_h 25 | end 26 | 27 | def to_a 28 | @value.to_a 29 | end 30 | end 31 | 32 | class API 33 | class << self 34 | # httpclient向けにパラメータHashを変換する 35 | def build_query(params, headers) 36 | # Hashで渡されるクエリパラメータをArray化する。 37 | # valueがArrayだった場合はkeyに[]を付加して平たくする。 38 | conv = [] 39 | params.each do |key, val| 40 | if val.is_a? Array 41 | val.each do |v| 42 | elem_key = "#{key.to_s}[]" 43 | conv << [elem_key, v] 44 | end 45 | else 46 | conv << [key.to_s, val] 47 | end 48 | end 49 | params = conv 50 | 51 | # valueの種類に応じてhttpclientに渡すものを変える 52 | files = [] 53 | to_multipart = params.any? {|key, value| value.is_a?(Pathname) || value.is_a?(Plugin::Photo::Photo) } 54 | 55 | if to_multipart 56 | headers << ["Content-Type", "multipart/form-data"] 57 | end 58 | 59 | params = params.map do |key, value| 60 | case value 61 | when Pathname 62 | # multipart/form-data にするが、POSTリクエストではない可能性がある(PATCH等)ため、ある程度自力でつくる。 63 | # boundary作成や実際のbody構築はhttpclientに任せる。 64 | filename = value.basename.to_s 65 | disposition = "form-data; name=\"#{key}\"; filename=\"#{filename}\"" 66 | f = File.open(value.to_s, 'rb') 67 | files << f 68 | { 69 | "Content-Type" => "application/octet-stream", 70 | "Content-Disposition" => disposition, 71 | :content => f, 72 | } 73 | when Plugin::Photo::Photo 74 | filename = Pathname(value.perma_link.path).basename.to_s 75 | disposition = "form-data; name=\"#{key}\"; filename=\"#{filename}\"" 76 | { 77 | "Content-Type" => "application/octet-stream", 78 | "Content-Disposition" => "form-data; name=\"#{key}\"; filename=\"#{filename}\"", 79 | :content => StringIO.new(value.blob, 'r'), 80 | } 81 | else 82 | if to_multipart 83 | { 84 | "Content-Type" => "application/octet-stream", 85 | "Content-Disposition" => "form-data; name=\"#{key}\"", 86 | :content => value, 87 | } 88 | else 89 | [key, value] 90 | end 91 | end 92 | end 93 | 94 | [params, headers, files] 95 | end 96 | 97 | # APIアクセスを行うhttpclientのラッパメソッド 98 | def call(method, domain, path = nil, access_token = nil, opts = {}, headers = [], **params) 99 | begin 100 | if domain.is_a? Diva::URI 101 | uri = domain 102 | domain = uri.host 103 | path = uri.path 104 | else 105 | url = 'https://' + domain + path 106 | uri = Diva::URI.new(url) 107 | end 108 | 109 | if access_token && !access_token.empty? 110 | headers += [["Authorization", "Bearer " + access_token]] 111 | end 112 | 113 | begin 114 | query, headers, files = build_query(params, headers) 115 | 116 | body = nil 117 | if method != :get # :post, :patch 118 | body = query 119 | query = nil 120 | end 121 | 122 | notice "Worldon::API.call #{method.to_s} #{uri} #{headers.to_s} #{query.to_s} #{body.to_s}" 123 | 124 | client = HTTPClient.new 125 | resp = client.request(method, uri.to_s, query, body, headers) 126 | ensure 127 | files.each do |f| 128 | f.close 129 | end 130 | end 131 | 132 | case resp.status 133 | when 200 134 | hash = JSON.parse(resp.content, symbolize_names: true) 135 | parse_Link(resp, hash) 136 | else 137 | warn "API.call did'nt return 200 Success" 138 | pp [uri.to_s, query, body, headers, resp] if Mopt.error_level >= 2 139 | $stdout.flush 140 | nil 141 | end 142 | rescue => e 143 | error "API.call raise exception" 144 | pp e if Mopt.error_level >= 1 145 | $stdout.flush 146 | nil 147 | end 148 | end 149 | 150 | def parse_Link(resp, hash) 151 | link = resp.header['Link'].first 152 | return APIResult.new(hash) if ((!hash.is_a? Array) || link.nil?) 153 | header = 154 | link 155 | .split(', ') 156 | .map do |line| 157 | /^<(.*)>; rel="(.*)"$/.match(line) do |m| 158 | [$2.to_sym, Diva::URI.new($1)] 159 | end 160 | end 161 | .to_h 162 | APIResult.new(hash, header) 163 | end 164 | 165 | def status(domain, id) 166 | call(:get, domain, '/api/v1/statuses/' + id.to_s) 167 | end 168 | 169 | def status_by_url(domain, access_token, url) 170 | resp = call(:get, domain, '/api/v1/search', access_token, q: url.to_s, resolve: true) 171 | return nil if resp.nil? 172 | resp[:statuses] 173 | end 174 | 175 | def account_by_url(domain, access_token, url) 176 | resp = call(:get, domain, '/api/v1/search', access_token, q: url.to_s, resolve: true) 177 | return nil if resp.nil? 178 | resp[:accounts] 179 | end 180 | 181 | def get_local_status_id(world, status) 182 | return status.id if world.domain == status.domain 183 | 184 | # 別インスタンス起源のstatusなので検索する 185 | statuses = status_by_url(world.domain, world.access_token, status.url) 186 | if statuses.nil? || statuses[0].nil? || statuses[0][:id].nil? 187 | nil 188 | else 189 | statuses[0][:id] 190 | end 191 | end 192 | 193 | def get_local_account_id(world, account) 194 | return account.id if world.domain == account.domain 195 | 196 | # 別インスタンス起源のaccountなので検索する 197 | accounts = account_by_url(world.domain, world.access_token, account.url) 198 | if accounts.nil? || accounts[0].nil? || accounts[0][:id].nil? 199 | nil 200 | else 201 | accounts[0][:id] 202 | end 203 | end 204 | 205 | # Link headerがあるAPIを連続的に叩いて1要素ずつyieldする 206 | # ==== Args 207 | # [method] HTTPメソッド 208 | # [domain] 対象ドメイン 209 | # [path] APIパス 210 | # [access_token] トークン 211 | # [opts] オプション 212 | # [:direction] :next or :prev 213 | # [:wait] APIコール間にsleepで待機する秒数 214 | # [headers] 追加ヘッダ 215 | # [params] GET/POSTパラメータ 216 | def all(method, domain, path = nil, access_token = nil, opts = {}, headers = [], **params) 217 | opts[:direction] ||= :next 218 | opts[:wait] ||= 1 219 | 220 | while true 221 | list = API.call(method, domain, path, access_token, opts, headers, **params) 222 | 223 | if list && list.value.is_a?(Array) 224 | list.value.each { |hash| yield hash } 225 | end 226 | 227 | break unless list.header.has_key?(opts[:direction]) 228 | 229 | url = list.header[opts[:direction]] 230 | params = URI.decode_www_form(url.query).to_h.symbolize 231 | 232 | sleep opts[:wait] 233 | end 234 | end 235 | 236 | def all_with_world(world, method, path = nil, opts = {}, headers = [], **params, &block) 237 | all(method, world.domain, path, world.access_token, opts, headers, **params, &block) 238 | end 239 | 240 | end 241 | end 242 | 243 | end 244 | -------------------------------------------------------------------------------- /worldon.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | require 'pp' 3 | 4 | module Plugin::Worldon 5 | PM = Plugin::Worldon 6 | CLIENT_NAME = 'mikutter Worldon' 7 | WEB_SITE = 'https://github.com/cobodo/mikutter-worldon' 8 | end 9 | 10 | require_relative 'util' 11 | require_relative 'api' 12 | require_relative 'parser' 13 | require_relative 'model/model' 14 | require_relative 'patch' 15 | require_relative 'spell' 16 | require_relative 'setting' 17 | require_relative 'subparts_status_info' 18 | require_relative 'extractcondition' 19 | require_relative 'sse_client' 20 | require_relative 'sse_stream' 21 | require_relative 'rest' 22 | require_relative 'score' 23 | 24 | Plugin.create(:worldon) do 25 | pm = Plugin::Worldon 26 | 27 | defimageopener('Mastodon添付画像', %r<\Ahttps?://[^/]+/system/media_attachments/files/[0-9]{3}/[0-9]{3}/[0-9]{3}/\w+/\w+\.\w+(?:\?\d+)?\Z>) do |url| 28 | open(url) 29 | end 30 | 31 | defimageopener('Mastodon添付画像(短縮)', %r<\Ahttps?://[^/]+/media/[0-9A-Za-z_-]+(?:\?\d+)?\Z>) do |url| 32 | open(url) 33 | end 34 | 35 | defimageopener('Mastodon添付画像(proxy)', %r<\Ahttps?://[^/]+/media_proxy/[0-9]+/(?:original|small)\z>) do |url| 36 | open(url) 37 | end 38 | 39 | defevent :worldon_appear_toots, prototype: [[pm::Status]] 40 | 41 | filter_extract_datasources do |dss| 42 | datasources = { worldon_appear_toots: "受信したすべてのトゥート(Worldon)" } 43 | [datasources.merge(dss)] 44 | end 45 | 46 | on_worldon_appear_toots do |statuses| 47 | Plugin.call(:extract_receive_message, :worldon_appear_toots, statuses) 48 | end 49 | 50 | followings_updater = Proc.new do 51 | activity(:system, "自分のプロフィールやフォロー関係を取得しています...") 52 | Plugin.filtering(:worldon_worlds, nil).first.to_a.each do |world| 53 | world.update_account 54 | world.blocks! 55 | world.followings(cache: false).next do |followings| 56 | activity(:system, "自分のプロフィールやフォロー関係の取得が完了しました(#{world.account.acct})") 57 | end 58 | Plugin.call(:world_modify, world) 59 | end 60 | 61 | Reserver.new(10 * HYDE, &followings_updater) # 26分ごとにプロフィールとフォロー一覧を更新する 62 | end 63 | 64 | # 起動時 65 | Delayer.new { 66 | followings_updater.call 67 | } 68 | 69 | 70 | # world系 71 | 72 | defevent :worldon_worlds, prototype: [NilClass] 73 | 74 | # すべてのworldon worldを返す 75 | filter_worldon_worlds do 76 | [Enumerator.new{|y| 77 | Plugin.filtering(:worlds, y) 78 | }.select{|world| 79 | world.class.slug == :worldon 80 | }.to_a] 81 | end 82 | 83 | defevent :worldon_current, prototype: [NilClass] 84 | 85 | # world_currentがworldonならそれを、そうでなければ適当に探す。 86 | filter_worldon_current do 87 | world, = Plugin.filtering(:world_current, nil) 88 | unless [:worldon, :portal].include?(world.class.slug) 89 | worlds, = Plugin.filtering(:worldon_worlds, nil) 90 | world = worlds.first 91 | end 92 | [world] 93 | end 94 | 95 | on_userconfig_modify do |key, value| 96 | if [:worldon_enable_streaming, :extract_tabs].include?(key) 97 | Plugin.call(:worldon_restart_all_streams) 98 | end 99 | end 100 | 101 | # 別プラグインからインスタンスを追加してストリームを開始する例 102 | # domain = 'friends.nico' 103 | # instance, = Plugin.filtering(:worldon_add_instance, domain) 104 | # Plugin.call(:worldon_restart_instance_stream, instance.domain) if instance 105 | filter_worldon_add_instance do |domain| 106 | [pm::Instance.add(domain)] 107 | end 108 | 109 | # インスタンス編集 110 | on_worldon_update_instance do |domain| 111 | Thread.new { 112 | instance = pm::Instance.load(domain) 113 | next if instance.nil? # 既存にない 114 | 115 | Plugin.call(:worldon_restart_instance_stream, domain) 116 | } 117 | end 118 | 119 | # インスタンス削除 120 | on_worldon_delete_instance do |domain| 121 | Plugin.call(:worldon_remove_instance_stream, domain) 122 | if UserConfig[:worldon_instances].has_key?(domain) 123 | config = UserConfig[:worldon_instances].dup 124 | config.delete(domain) 125 | UserConfig[:worldon_instances] = config 126 | end 127 | end 128 | 129 | # world追加時用 130 | on_worldon_create_or_update_instance do |domain| 131 | Thread.new { 132 | instance = pm::Instance.load(domain) 133 | if instance.nil? 134 | instance, = Plugin.filtering(:worldon_add_instance, domain) 135 | end 136 | next if instance.nil? # 既存にない&接続失敗 137 | 138 | Plugin.call(:worldon_restart_instance_stream, domain) 139 | } 140 | end 141 | 142 | # world追加 143 | on_world_create do |world| 144 | if world.class.slug == :worldon 145 | Delayer.new { 146 | Plugin.call(:worldon_create_or_update_instance, world.domain, true) 147 | } 148 | end 149 | end 150 | 151 | # world削除 152 | on_world_destroy do |world| 153 | if world.class.slug == :worldon 154 | Delayer.new { 155 | worlds = Plugin.filtering(:worldon_worlds, nil).first 156 | # 他のworldで使わなくなったものは削除してしまう。 157 | # filter_worldsから削除されるのはココと同様にon_world_destroyのタイミングらしいので、 158 | # この時点では削除済みである保証はなく、そのためworld.slugで判定する必要がある(はず)。 159 | unless worlds.any?{|w| w.slug != world.slug && w.domain != world.domain } 160 | Plugin.call(:worldon_delete_instance, world.domain) 161 | end 162 | Plugin.call(:worldon_remove_auth_stream, world) 163 | } 164 | end 165 | end 166 | 167 | # world作成 168 | world_setting(:worldon, _('Mastodon(Worldon)')) do 169 | error_msg = nil 170 | while true 171 | if error_msg.is_a? String 172 | label error_msg 173 | end 174 | input 'インスタンスのドメイン', :domain 175 | 176 | result = await_input 177 | domain = result[:domain] 178 | 179 | instance = pm::Instance.load(domain) 180 | if instance.nil? 181 | # 既存にないので追加 182 | instance, = Plugin.filtering(:worldon_add_instance, domain) 183 | if instance.nil? 184 | # 追加失敗 185 | error_msg = "#{domain} インスタンスへの接続に失敗しました。やり直してください。" 186 | next 187 | end 188 | end 189 | 190 | break 191 | end 192 | 193 | error_msg = nil 194 | while true 195 | if error_msg.is_a? String 196 | label error_msg 197 | end 198 | label 'Webページにアクセスして表示された認証コードを入力して、次へボタンを押してください。' 199 | link instance.authorize_url 200 | puts instance.authorize_url # ブラウザで開けない時のため 201 | $stdout.flush 202 | input '認証コード', :authorization_code 203 | if error_msg.is_a? String 204 | input 'アクセストークンがあれば入力してください', :access_token 205 | end 206 | result = await_input 207 | if result[:authorization_code] 208 | result[:authorization_code].strip! 209 | end 210 | if result[:access_token] 211 | result[:access_token].strip! 212 | end 213 | 214 | if ((result[:authorization_code].nil? || result[:authorization_code].empty?) && (result[:access_token].nil? || result[:access_token].empty?)) 215 | error_msg = "認証コードを入力してください" 216 | next 217 | end 218 | 219 | break 220 | end 221 | 222 | if result[:authorization_code] 223 | resp = pm::API.call(:post, domain, '/oauth/token', 224 | client_id: instance.client_key, 225 | client_secret: instance.client_secret, 226 | grant_type: 'authorization_code', 227 | redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', 228 | code: result[:authorization_code] 229 | ) 230 | if resp.nil? || resp.value.has_key?(:error) 231 | label "認証に失敗しました#{resp && resp[:error] ? ":#{resp[:error]}" : ''}" 232 | await_input 233 | raise (resp.nil? ? 'error has occurred at /oauth/token' : resp[:error]) 234 | end 235 | token = resp[:access_token] 236 | else 237 | token = result[:access_token] 238 | end 239 | 240 | resp = pm::API.call(:get, domain, '/api/v1/accounts/verify_credentials', token) 241 | if resp.nil? || resp.value.has_key?(:error) 242 | label "アカウント情報の取得に失敗しました#{resp && resp[:error] ? ":#{resp[:error]}" : ''}" 243 | raise (resp.nil? ? 'error has occurred at verify_credentials' : resp[:error]) 244 | end 245 | 246 | screen_name = resp[:acct] + '@' + domain 247 | resp[:acct] = screen_name 248 | account = pm::Account.new(resp.value) 249 | world = pm::World.new( 250 | id: screen_name, 251 | slug: :"worldon:#{screen_name}", 252 | domain: domain, 253 | access_token: token, 254 | account: account 255 | ) 256 | world.update_mutes! 257 | 258 | label '認証に成功しました。このアカウントを追加しますか?' 259 | label('アカウント名:' + screen_name) 260 | label('ユーザー名:' + resp[:display_name]) 261 | world 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /model/world.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | module Plugin::Worldon 3 | class World < Diva::Model 4 | extend Memoist 5 | 6 | register :worldon, name: "Mastodon(Worldon)" 7 | 8 | field.string :id, required: true 9 | field.string :slug, required: true 10 | alias :name :slug 11 | field.string :domain, required: true 12 | field.string :access_token, required: true 13 | field.has :account, Account, required: true 14 | 15 | alias :user_obj :account 16 | 17 | @@lists = Hash.new 18 | @@followings = Hash.new 19 | @@followers = Hash.new 20 | @@blocks = Hash.new 21 | 22 | memoize def path 23 | "/#{account.acct.split('@').reverse.join('/')}" 24 | end 25 | 26 | def inspect 27 | "worldon-world(#{account.acct})" 28 | end 29 | 30 | def icon 31 | account.icon 32 | end 33 | 34 | def title 35 | account.title 36 | end 37 | 38 | def datasource_slug(type, n = nil) 39 | case type 40 | when :home 41 | # ホームTL 42 | "worldon-#{account.acct}-home".to_sym 43 | when :direct 44 | # DM TL 45 | "worldon-#{account.acct}-direct".to_sym 46 | when :list 47 | # リストTL 48 | "worldon-#{account.acct}-list-#{n}".to_sym 49 | else 50 | "worldon-#{account.acct}-#{type.to_s}".to_sym 51 | end 52 | end 53 | 54 | def get_lists! 55 | return @@lists[uri.to_s] if @@lists[uri.to_s] 56 | 57 | lists = API.call(:get, domain, '/api/v1/lists', access_token) 58 | if lists.nil? 59 | warn "[worldon] failed to get lists" 60 | elsif lists.value.is_a? Array 61 | @@lists[uri.to_s] = lists.value 62 | elsif lists.value.is_a?(Hash) && lists['error'] 63 | warn "[worldon] failed to get lists: #{lists['error'].to_s}" 64 | end 65 | @@lists[uri.to_s] 66 | end 67 | 68 | def update_mutes! 69 | params = { limit: 80 } 70 | since_id = nil 71 | Status.clear_mutes 72 | while mutes = PM::API.call(:get, domain, '/api/v1/mutes', access_token, **params) 73 | Status.add_mutes(mutes.value) 74 | if mutes.header.nil? || mutes.header[:prev].nil? 75 | return 76 | end 77 | url = mutes.header[:prev] 78 | params = URI.decode_www_form(url.query).to_h.map{|k,v| [k.to_sym, v] }.to_h 79 | return if params[:since_id].to_i == since_id 80 | since_id = params[:since_id].to_i 81 | 82 | sleep 1 83 | end 84 | end 85 | 86 | # 投稿する 87 | # opts[:in_reply_to_id] Integer 返信先Statusの(ローカル)ID 88 | # opts[:media_ids] Array 添付画像IDの配列(最大4) 89 | # opts[:sensitive] True | False NSFWフラグの明示的な指定 90 | # opts[:spoiler_text] String ContentWarning用のコメント 91 | # opts[:visibility] String 公開範囲。 "direct", "private", "unlisted", "public" のいずれか。 92 | def post(to: nil, message:, **params) 93 | params[:status] = message 94 | if to 95 | status_id = API.get_local_status_id(self, to) 96 | if status_id 97 | params[:in_reply_to_id] = status_id 98 | else 99 | warn "返信先Statusが#{world.domain}内に見つかりませんでした:#{status.url}" 100 | return nil 101 | end 102 | end 103 | API.call(:post, domain, '/api/v1/statuses', access_token, **params) 104 | end 105 | 106 | def do_reblog(status) 107 | status_id = PM::API.get_local_status_id(self, status.actual_status) 108 | if status_id.nil? 109 | error 'cannot get local status id' 110 | return nil 111 | end 112 | 113 | new_status_hash = PM::API.call(:post, domain, '/api/v1/statuses/' + status_id.to_s + '/reblog', access_token) 114 | if new_status_hash.nil? || new_status_hash.value.has_key?(:error) 115 | error 'failed reblog request' 116 | pp new_status_hash if Mopt.error_level >= 1 117 | $stdout.flush 118 | return nil 119 | end 120 | 121 | new_status = PM::Status.build(domain, [new_status_hash.value]).first 122 | return if new_status.nil? 123 | 124 | status.actual_status.reblogged = true 125 | status.reblog_status_uris << { uri: new_status.original_uri, acct: account.acct } 126 | status.reblog_status_uris.uniq! 127 | Plugin.call(:retweet, [new_status]) 128 | 129 | status_world = status.from_me_world 130 | if status_world 131 | Plugin.call(:mention, status_world, [new_status]) 132 | end 133 | end 134 | 135 | def reblog(status) 136 | promise = Delayer::Deferred.new(true) 137 | Thread.new do 138 | begin 139 | new_status = do_reblog(status) 140 | if new_status.is_a? Status 141 | promise.call(new_status) 142 | else 143 | promise.fail(new_status) 144 | end 145 | rescue Exception => e 146 | pp e if Mopt.error_level >= 2 # warn 147 | $stdout.flush 148 | promise.fail(e) 149 | end 150 | end 151 | promise 152 | end 153 | 154 | def get_accounts!(type) 155 | promise = Delayer::Deferred.new(true) 156 | Thread.new do 157 | begin 158 | accounts = [] 159 | params = { 160 | limit: 80 161 | } 162 | API.all_with_world(self, :get, "/api/v1/accounts/#{account.id}/#{type}", **params) do |hash| 163 | accounts << hash 164 | end 165 | promise.call(accounts.map {|hash| Account.new hash }) 166 | rescue Exception => e 167 | pp e if Mopt.error_level >= 2 # warn 168 | $stdout.flush 169 | promise.fail("failed to get #{type}") 170 | end 171 | end 172 | promise 173 | end 174 | 175 | def following?(acct) 176 | acct = acct.acct if acct.is_a?(Account) 177 | @@followings[uri.to_s].to_a.any? { |account| account.acct == acct } 178 | end 179 | 180 | def followings(cache: true, **opts) 181 | promise = Delayer::Deferred.new(true) 182 | Thread.new do 183 | next promise.call(@@followings[uri.to_s]) if cache && @@followings[uri.to_s] 184 | get_accounts!('following').next do |accounts| 185 | @@followings[uri.to_s] = accounts 186 | promise.call(accounts) 187 | end 188 | end 189 | promise 190 | end 191 | 192 | def followers(cache: true, **opts) 193 | promise = Delayer::Deferred.new(true) 194 | Thread.new do 195 | next promise.call(@@followers[uri.to_s]) if cache && @@followers[uri.to_s] 196 | get_accounts!('followers').next do |accounts| 197 | @@followers[uri.to_s] = accounts 198 | promise.call(accounts) 199 | end 200 | end 201 | promise 202 | end 203 | 204 | def blocks! 205 | promise = Delayer::Deferred.new(true) 206 | Thread.new do 207 | begin 208 | accounts = [] 209 | params = { 210 | limit: 80 211 | } 212 | API.all_with_world(self, :get, "/api/v1/blocks", **params) do |hash| 213 | accounts << hash 214 | end 215 | @@blocks[uri.to_s] = accounts.map { |hash| Account.new hash } 216 | promise.call(@@blocks[uri.to_s]) 217 | rescue Exception => e 218 | pp e if Mopt.error_level >= 2 # warn 219 | $stdout.flush 220 | promise.fail('failed to get blocks') 221 | end 222 | end 223 | promise 224 | end 225 | 226 | def block?(acct) 227 | @@blocks[uri.to_s].to_a.any? { |acc| acc.acct == acct } 228 | end 229 | 230 | def account_action(account, type) 231 | account_id = PM::API.get_local_account_id(self, account) 232 | PM::API.call(:post, domain, "/api/v1/accounts/#{account_id}/#{type}", access_token) 233 | end 234 | 235 | def follow(account) 236 | ret = account_action(account, "follow") 237 | 238 | if ret 239 | @@followings[uri.to_s] = Array() unless @@followings[uri.to_s] 240 | @@followings[uri.to_s] << account 241 | followings(cache: false) 242 | end 243 | 244 | ret 245 | end 246 | 247 | def unfollow(account) 248 | ret = account_action(account, "unfollow") 249 | 250 | if ret && @@followings[uri.to_s] 251 | @@followings[uri.to_s].delete_if do |acc| 252 | acc.acct == account.acct 253 | end 254 | 255 | followings(cache: false) 256 | end 257 | 258 | ret 259 | end 260 | 261 | def mute(account) 262 | account_action(account, "mute") 263 | update_mutes! 264 | end 265 | 266 | def unmute(account) 267 | account_action(account, "unmute") 268 | update_mutes! 269 | end 270 | 271 | def block(account) 272 | account_action(account, "block") 273 | blocks! 274 | end 275 | 276 | def unblock(account) 277 | account_action(account, "unblock") 278 | blocks! 279 | end 280 | 281 | def pin(status) 282 | status_id = PM::API.get_local_status_id(self, status) 283 | PM::API.call(:post, domain, "/api/v1/statuses/#{status_id}/pin", access_token) 284 | status.pinned = true 285 | end 286 | 287 | def unpin(status) 288 | status_id = PM::API.get_local_status_id(self, status) 289 | PM::API.call(:post, domain, "/api/v1/statuses/#{status_id}/unpin", access_token) 290 | status.pinned = false 291 | end 292 | 293 | def report_for_spam(statuses, comment) 294 | account_id = PM::API.get_local_account_id(self, statuses.first.account) 295 | params = { 296 | account_id: account_id, 297 | status_ids: statuses.map { |status| PM::API.get_local_status_id(self, status) }, 298 | comment: comment, 299 | } 300 | PM::API.call(:post, domain, "/api/v1/reports", access_token, **params) 301 | end 302 | 303 | def update_account 304 | resp = PM::API.call(:get, domain, '/api/v1/accounts/verify_credentials', access_token) 305 | if resp.nil? || resp.value.has_key?(:error) 306 | warn(resp.nil? ? 'error has occurred at verify_credentials' : resp[:error]) 307 | return 308 | end 309 | 310 | resp[:acct] = resp[:acct] + '@' + domain 311 | self.account = PM::Account.new(resp.value) 312 | end 313 | 314 | def update_profile(**opts) 315 | params = {} 316 | params[:display_name] = opts[:name] if opts[:name] 317 | params[:note] = opts[:biography] if opts[:biography] 318 | params[:locked] = opts[:locked] if !opts[:locked].nil? 319 | params[:bot] = opts[:bot] if !opts[:bot].nil? 320 | ds = [] 321 | if opts[:icon] 322 | if opts[:icon].is_a?(Plugin::Photo::Photo) 323 | ds << opts[:icon].download.next{|photo| [:avatar, photo] } 324 | else 325 | params[:avatar] = opts[:icon] 326 | end 327 | end 328 | if opts[:header] 329 | if opts[:header].is_a?(Plugin::Photo::Photo) 330 | ds << opts[:header].download.next{|photo| [:header, photo] } 331 | else 332 | params[:header] = opts[:header] 333 | end 334 | end 335 | if ds.empty? 336 | ds << Delayer::Deferred.new.next{ [:none, nil] } 337 | end 338 | Delayer::Deferred.when(ds).next{|vs| 339 | vs.each do |key, val| 340 | params[key] = val 341 | end 342 | new_account = PM::API.call(:patch, domain, '/api/v1/accounts/update_credentials', access_token, **params) 343 | if new_account.value 344 | self.account = PM::Account.new(new_account.value) 345 | Plugin.call(:world_modify, self) 346 | end 347 | } 348 | end 349 | end 350 | end 351 | -------------------------------------------------------------------------------- /sse_stream.rb: -------------------------------------------------------------------------------- 1 | require_relative 'sse_client' 2 | 3 | Plugin.create(:worldon) do 4 | pm = Plugin::Worldon 5 | 6 | # ストリーム開始&直近取得イベント 7 | defevent :worldon_start_stream, prototype: [String, String, String, pm::World, Integer] 8 | 9 | def datasource_used?(slug, include_all = false) 10 | return false if UserConfig[:extract_tabs].nil? 11 | UserConfig[:extract_tabs].any? do |setting| 12 | setting[:sources].any? do |ds| 13 | ds == slug || include_all && ds == :worldon_appear_toots 14 | end 15 | end 16 | end 17 | 18 | on_worldon_start_stream do |domain, type, slug, world, list_id| 19 | next if !UserConfig[:worldon_enable_streaming] 20 | 21 | Thread.new { 22 | sleep(rand(10)) 23 | 24 | token = nil 25 | if world.is_a? pm::World 26 | token = world.access_token 27 | end 28 | 29 | base_url = 'https://' + domain + '/api/v1/streaming/' 30 | params = {} 31 | case type 32 | when 'user' 33 | uri = Diva::URI.new(base_url + 'user') 34 | when 'public' 35 | uri = Diva::URI.new(base_url + 'public') 36 | when 'public:media' 37 | uri = Diva::URI.new(base_url + 'public') 38 | params[:only_media] = true 39 | when 'public:local' 40 | uri = Diva::URI.new(base_url + 'public/local') 41 | when 'public:local:media' 42 | uri = Diva::URI.new(base_url + 'public/local') 43 | params[:only_media] = true 44 | when 'list' 45 | uri = Diva::URI.new(base_url + 'list') 46 | params[:list] = list_id 47 | when 'direct' 48 | uri = Diva::URI.new(base_url + 'direct') 49 | end 50 | 51 | headers = {} 52 | if token 53 | headers["Authorization"] = "Bearer " + token 54 | end 55 | 56 | Plugin.call(:sse_create, slug, :get, uri, headers, params, domain: domain, type: type, token: token) 57 | } 58 | end 59 | 60 | on_worldon_stop_stream do |slug| 61 | Plugin.call(:sse_kill_connection, slug) 62 | end 63 | 64 | # mikutterにとって自明に60秒以上過去となる任意の日時 65 | @last_all_restarted = Time.new(2007, 8, 31, 0, 0, 0, "+09:00") 66 | @waiting = false 67 | 68 | restarter = Proc.new do 69 | if @waiting 70 | Plugin.call(:sse_kill_all, :worldon_start_all_streams) 71 | atomic { 72 | @last_all_restarted = Time.new 73 | @waiting = false 74 | } 75 | end 76 | atomic { 77 | @waiting = false 78 | } 79 | 80 | Reserver.new(60, &restarter) 81 | end 82 | 83 | on_worldon_restart_all_streams do 84 | now = Time.new 85 | atomic { 86 | @waiting = true 87 | } 88 | if (now - @last_all_restarted) >= 60 89 | restarter.call 90 | end 91 | end 92 | 93 | on_worldon_start_all_streams do 94 | worlds, = Plugin.filtering(:worldon_worlds, nil) 95 | 96 | worlds.each do |world| 97 | Thread.new { 98 | world.update_mutes! 99 | Plugin.call(:worldon_init_auth_stream, world) 100 | } 101 | end 102 | 103 | UserConfig[:worldon_instances].map do |domain, setting| 104 | Plugin.call(:worldon_init_instance_stream, domain) 105 | end 106 | end 107 | 108 | # インスタンスストリームを必要に応じて再起動 109 | on_worldon_restart_instance_stream do |domain, retrieve = true| 110 | Thread.new { 111 | instance = pm::Instance.load(domain) 112 | if instance.retrieve != retrieve 113 | instance.retrieve = retrieve 114 | instance.store 115 | end 116 | 117 | Plugin.call(:worldon_remove_instance_stream, domain) 118 | if retrieve 119 | Plugin.call(:worldon_init_instance_stream, domain) 120 | end 121 | } 122 | end 123 | 124 | on_worldon_init_instance_stream do |domain| 125 | Thread.new { 126 | instance = pm::Instance.load(domain) 127 | 128 | pm::Instance.add_datasources(domain) 129 | 130 | ftl_slug = pm::Instance.datasource_slug(domain, :federated) 131 | ftl_media_slug = pm::Instance.datasource_slug(domain, :federated_media) 132 | ltl_slug = pm::Instance.datasource_slug(domain, :local) 133 | ltl_media_slug = pm::Instance.datasource_slug(domain, :local_media) 134 | 135 | # ストリーム開始 136 | Plugin.call(:worldon_start_stream, domain, 'public', ftl_slug) if datasource_used?(ftl_slug, true) 137 | Plugin.call(:worldon_start_stream, domain, 'public:media', ftl_media_slug) if datasource_used?(ftl_media_slug) 138 | Plugin.call(:worldon_start_stream, domain, 'public:local', ltl_slug) if datasource_used?(ltl_slug) 139 | Plugin.call(:worldon_start_stream, domain, 'public:local:media', ltl_media_slug) if datasource_used?(ltl_media_slug) 140 | } 141 | end 142 | 143 | on_worldon_remove_instance_stream do |domain| 144 | Plugin.call(:worldon_stop_stream, pm::Instance.datasource_slug(domain, :federated)) 145 | Plugin.call(:worldon_stop_stream, pm::Instance.datasource_slug(domain, :local)) 146 | pm::Instance.remove_datasources(domain) 147 | end 148 | 149 | on_worldon_init_auth_stream do |world| 150 | Thread.new { 151 | lists = world.get_lists! 152 | 153 | filter_extract_datasources do |dss| 154 | instance = pm::Instance.load(world.domain) 155 | datasources = { 156 | world.datasource_slug(:home) => "Mastodonホームタイムライン(Worldon)/#{world.account.acct}", 157 | world.datasource_slug(:direct) => "Mastodon DM(Worldon)/#{world.account.acct}", 158 | } 159 | lists.to_a.each do |l| 160 | slug = world.datasource_slug(:list, l[:id]) 161 | datasources[slug] = "Mastodonリスト(Worldon)/#{world.account.acct}/#{l[:title]}" 162 | end 163 | [datasources.merge(dss)] 164 | end 165 | 166 | # ストリーム開始 167 | if datasource_used?(world.datasource_slug(:home), true) 168 | Plugin.call(:worldon_start_stream, world.domain, 'user', world.datasource_slug(:home), world) 169 | end 170 | if datasource_used?(world.datasource_slug(:direct), true) 171 | Plugin.call(:worldon_start_stream, world.domain, 'direct', world.datasource_slug(:direct), world) 172 | end 173 | 174 | lists.to_a.each do |l| 175 | id = l[:id].to_i 176 | slug = world.datasource_slug(:list, id) 177 | if datasource_used?(world.datasource_slug(:list, id)) 178 | Plugin.call(:worldon_start_stream, world.domain, 'list', world.datasource_slug(:list, id), world, id) 179 | end 180 | end 181 | } 182 | end 183 | 184 | on_worldon_remove_auth_stream do |world| 185 | slugs = [] 186 | slugs.push world.datasource_slug(:home) 187 | slugs.push world.datasource_slug(:direct) 188 | 189 | lists = world.get_lists! 190 | lists.to_a.each do |l| 191 | id = l[:id].to_i 192 | slugs.push world.datasource_slug(:list, id) 193 | end 194 | 195 | slugs.each do |slug| 196 | Plugin.call(:worldon_stop_stream, slug) 197 | end 198 | 199 | filter_extract_datasources do |datasources| 200 | slugs.each do |slug| 201 | datasources.delete slug 202 | end 203 | [datasources] 204 | end 205 | end 206 | 207 | on_worldon_restart_sse_stream do |slug| 208 | Thread.new { 209 | connection, = Plugin.filtering(:sse_connection, slug) 210 | if connection.nil? 211 | # 終了済み 212 | next 213 | end 214 | Plugin.call(:sse_kill_connection, slug) 215 | 216 | sleep(rand(3..10)) 217 | Plugin.call(:sse_create, slug, :get, connection[:uri], connection[:headers], connection[:params], connection[:opts]) 218 | } 219 | end 220 | 221 | on_sse_connection_opening do |slug| 222 | notice "SSE: connection open for #{slug.to_s}" 223 | end 224 | 225 | on_sse_connection_failure do |slug, response| 226 | error "SSE: connection failure for #{slug.to_s}" 227 | pp response if Mopt.error_level >= 1 228 | $stdout.flush 229 | 230 | if (response.status / 100) == 4 231 | # 4xx系レスポンスはリトライせず終了する 232 | Plugin.call(:sse_kill_connection, slug) 233 | else 234 | Plugin.call(:worldon_restart_sse_stream, slug) 235 | end 236 | end 237 | 238 | on_sse_connection_closed do |slug| 239 | warn "SSE: connection closed for #{slug.to_s}" 240 | 241 | Plugin.call(:worldon_restart_sse_stream, slug) 242 | end 243 | 244 | on_sse_connection_error do |slug, e| 245 | error "SSE: connection error for #{slug.to_s}" 246 | pp e if Mopt.error_level >= 1 247 | 248 | Plugin.call(:worldon_restart_sse_stream, slug) 249 | end 250 | 251 | on_sse_on_update do |slug, json| 252 | Thread.new { 253 | data = JSON.parse(json, symbolize_names: true) 254 | update_handler(slug, data) 255 | } 256 | end 257 | 258 | on_sse_on_notification do |slug, json| 259 | Thread.new { 260 | data = JSON.parse(json, symbolize_names: true) 261 | notification_handler(slug, data) 262 | } 263 | end 264 | 265 | on_sse_on_delete do |slug, id| 266 | # 消す必要ある? 267 | # pawooは一定時間後(1分~7日後)に自動消滅するtootができる拡張をしている。 268 | # また、手動で即座に消す人もいる。 269 | # これは後からアクセスすることはできないがTLに流れてきたものは、 270 | # フォローした人々には見えている、という前提があるように思う。 271 | # だから消さないよ。 272 | end 273 | 274 | on_unload do 275 | Plugin.call(:sse_kill_all) 276 | end 277 | 278 | def stream_world(domain, access_token) 279 | Enumerator.new{|y| 280 | Plugin.filtering(:worldon_worlds, nil).first 281 | }.lazy.select{|world| 282 | world.domain == domain && world.access_token == access_token 283 | }.first 284 | end 285 | 286 | def update_handler(datasource_slug, payload) 287 | pm = Plugin::Worldon 288 | 289 | connection, = Plugin.filtering(:sse_connection, datasource_slug) 290 | domain = connection[:opts][:domain] 291 | access_token = connection[:opts][:token] 292 | status = pm::Status.build(domain, [payload]).first 293 | return if status.nil? 294 | 295 | Plugin.call(:extract_receive_message, datasource_slug, [status]) 296 | world = stream_world(domain, access_token) 297 | Plugin.call(:update, world, [status]) 298 | if (status&.reblog).is_a?(pm::Status) 299 | Plugin.call(:retweet, [status]) 300 | world = status.to_me_world 301 | if !world.nil? 302 | Plugin.call(:mention, world, [status]) 303 | end 304 | end 305 | end 306 | 307 | def notification_handler(datasource_slug, payload) 308 | pm = Plugin::Worldon 309 | 310 | connection, = Plugin.filtering(:sse_connection, datasource_slug) 311 | domain = connection[:opts][:domain] 312 | access_token = connection[:opts][:token] 313 | 314 | case payload[:type] 315 | when 'mention' 316 | status = pm::Status.build(domain, [payload[:status]]).first 317 | return if status.nil? 318 | Plugin.call(:extract_receive_message, datasource_slug, [status]) 319 | world = status.to_me_world 320 | if !world.nil? 321 | Plugin.call(:mention, world, [status]) 322 | end 323 | 324 | when 'reblog' 325 | user_id = payload[:account][:id] 326 | user_statuses = pm::API.call(:get, domain, "/api/v1/accounts/#{user_id}/statuses", access_token) 327 | if user_statuses.nil? 328 | error "Worldon: ブーストStatusの取得に失敗" 329 | return 330 | end 331 | idx = user_statuses.value.index do |hash| 332 | hash[:reblog] && hash[:reblog][:uri] == payload[:status][:uri] 333 | end 334 | if idx.nil? 335 | error "Worldon: ブーストStatusの取得に失敗(流れ速すぎ?)" 336 | return 337 | end 338 | 339 | status = pm::Status.build(domain, [user_statuses[idx]]).first 340 | return if status.nil? 341 | Plugin.call(:retweet, [status]) 342 | world = status.to_me_world 343 | if world 344 | Plugin.call(:mention, world, [status]) 345 | end 346 | 347 | when 'favourite' 348 | user = pm::Account.new(payload[:account]) 349 | status = pm::Status.build(domain, [payload[:status]]).first 350 | return if status.nil? 351 | status.favorite_accts << user.acct 352 | world = status.from_me_world 353 | status.set_modified(Time.now.localtime) if UserConfig[:favorited_by_anyone_age] and (UserConfig[:favorited_by_myself_age] or world.user_obj != user) 354 | if user && status && world 355 | Plugin.call(:favorite, world, user, status) 356 | end 357 | 358 | when 'follow' 359 | user = pm::Account.new payload[:account] 360 | world = stream_world(domain, access_token) 361 | if !world.nil? 362 | Plugin.call(:followers_created, world, [user]) 363 | end 364 | 365 | else 366 | # 未知の通知 367 | warn 'unknown notification' 368 | pp data if Mopt.error_level >= 2 369 | $stdout.flush 370 | end 371 | end 372 | end 373 | -------------------------------------------------------------------------------- /model/status.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module Plugin::Worldon 4 | # https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status 5 | # 必ずStatus.buildメソッドを通して生成すること 6 | class Status < Diva::Model 7 | include Diva::Model::MessageMixin 8 | 9 | register :worldon_status, name: "Mastodonステータス(Worldon)", timeline: true, reply: true, myself: true 10 | 11 | field.string :id, required: true 12 | field.string :original_uri, required: true # APIから取得するfediverse uniqueなURI文字列 13 | field.uri :url, required: true 14 | field.has :account, Account, required: true 15 | field.string :in_reply_to_id 16 | field.string :in_reply_to_account_id 17 | field.has :reblog, Status 18 | field.string :content, required: true 19 | field.time :created_at, required: true 20 | field.time :modified 21 | field.time :created 22 | field.int :reblogs_count 23 | field.int :favourites_count 24 | field.bool :reblogged 25 | field.bool :favourited 26 | field.bool :muted 27 | field.bool :sensitive 28 | field.string :spoiler_text 29 | field.string :visibility 30 | field.has :application, Application 31 | field.string :language 32 | field.bool :pinned 33 | 34 | field.string :domain, required: true # APIには無い追加フィールド 35 | 36 | field.has :emojis, [Emoji] 37 | field.has :media_attachments, [Attachment] 38 | field.has :mentions, [Mention] 39 | field.has :tags, [Tag] 40 | 41 | attr_accessor :reblog_status_uris # :: [String] APIには無い追加フィールド 42 | # ブーストしたStatusのuri(これらはreblogフィールドの値としてこのオブジェクトを持つ)と、acctを保持する。 43 | attr_accessor :favorite_accts # :: [String] APIには無い追加フィールド 44 | attr_accessor :description 45 | attr_accessor :score 46 | 47 | alias :uri :url # mikutter側の都合で、URI.parse可能である必要がある(API仕様上のuriフィールドとは異なる)。 48 | alias :perma_link :url 49 | alias :muted? :muted 50 | alias :pinned? :pinned 51 | alias :retweet_ancestor :reblog 52 | alias :sensitive? :sensitive # NSFW系プラグイン用 53 | 54 | @@mute_mutex = Thread::Mutex.new 55 | 56 | @@status_storage = WeakStorage.new(String, Status) 57 | 58 | TOOT_URI_RE = %r!\Ahttps://([^/]+)/@\w{1,30}/(\d+)\z!.freeze 59 | TOOT_ACTIVITY_URI_RE = %r!\Ahttps://(?[^/]*)/users/(?[^/]*)/statuses/(?[^/]*)/activity\z!.freeze 60 | 61 | handle TOOT_URI_RE do |uri| 62 | Status.findbyurl(uri) || Thread.new { Status.fetch(uri) } 63 | end 64 | 65 | class << self 66 | def add_mutes(account_hashes) 67 | @@mute_mutex.synchronize { 68 | @@mutes ||= [] 69 | @@mutes += account_hashes.map do |hash| 70 | hash = Account.regularize_acct hash 71 | hash[:acct] 72 | end 73 | @@mutes = @@mutes.uniq 74 | } 75 | end 76 | 77 | def clear_mutes 78 | @@mute_mutex.synchronize { 79 | @@mutes = [] 80 | } 81 | end 82 | 83 | def muted?(acct) 84 | @@mute_mutex.synchronize { 85 | @@mutes.any? { |a| a == acct } 86 | } 87 | end 88 | 89 | def build(domain_name, json) 90 | return [] if json.nil? 91 | return build(domain_name, [json]) if json.is_a? Hash 92 | 93 | json.map do |record| 94 | json2status(domain_name, record) 95 | end.compact.tap do |statuses| 96 | Plugin.call(:worldon_appear_toots, statuses) 97 | end 98 | end 99 | 100 | def json2status(domain_name, record) 101 | record[:domain] = domain_name 102 | is_boost = false 103 | 104 | if record[:reblog] 105 | is_boost = true 106 | 107 | boost_record = Util.deep_dup(record) 108 | boost_record[:reblog] = nil 109 | 110 | record = record[:reblog] 111 | record[:domain] = domain_name 112 | end 113 | uri = record[:url] # quoting_messages等のために@@status_storageには:urlで入れておく 114 | 115 | status = merge_or_create(domain_name, uri, record) 116 | return nil if status.nil? 117 | 118 | # ブーストの処理 119 | if !is_boost 120 | status 121 | # ブーストではないので、普通にstatusを返す。 122 | else 123 | boost_uri = boost_record[:uri] # reblogには:urlが無いので:uriで入れておく 124 | boost = merge_or_create(domain_name, boost_uri, boost_record) 125 | return nil if boost.nil? 126 | status.reblog_status_uris << { uri: boost_uri, acct: boost_record[:account][:acct] } 127 | status.reblog_status_uris.uniq! 128 | 129 | # ageなどの対応 130 | status.set_modified(boost.modified) if UserConfig[:retweeted_by_anyone_age] and (UserConfig[:retweeted_by_myself_age] or !boost.account.me?) 131 | 132 | boost[:retweet] = boost.reblog = status 133 | # わかりづらいが「ブーストした」statusの'reblog'プロパティにブースト元のstatusを入れている 134 | @@status_storage[boost_uri] = boost 135 | boost 136 | # 「ブーストした」statusを返す(appearしたのはそれに間違いないので。ブースト元はdon't care。 137 | # Gtk::TimeLine#block_addではmessage.retweet?ならmessage.retweet_sourceを取り出して追加する。 138 | end 139 | end 140 | 141 | # urlで検索する。 142 | # 但しブーストの場合はfediverse uri 143 | def findbyurl(url) 144 | @@status_storage[url] 145 | end 146 | 147 | def merge_or_create(domain_name, uri, new_hash) 148 | @@mutes ||= [] 149 | if new_hash[:account] && new_hash[:account][:acct] 150 | account_hash = Account.regularize_acct(new_hash[:account]) 151 | if @@mutes.index(account_hash[:acct]) 152 | return nil 153 | end 154 | end 155 | 156 | status = @@status_storage[uri] 157 | if status 158 | status = status.merge(domain_name, new_hash) 159 | else 160 | status = Status.new(new_hash) 161 | end 162 | @@status_storage[uri] = status 163 | status 164 | end 165 | 166 | def fetch(uri) 167 | if m = TOOT_URI_RE.match(uri.to_s) 168 | domain_name = m[1] 169 | id = m[2] 170 | resp = Plugin::Worldon::API.status(domain_name, id) 171 | return nil if resp.nil? 172 | Status.build(domain_name, [resp.value]).first 173 | end 174 | end 175 | end 176 | 177 | def initialize(hash) 178 | @reblog_status_uris = [] 179 | @favorite_accts = [] 180 | 181 | # タイムゾーン考慮 182 | hash[:created_at] = Time.parse(hash[:created_at]).localtime 183 | # cairo_sub_parts_message_base用 184 | hash[:created] = hash[:created_at] 185 | hash[:modified] = hash[:created_at] unless hash[:modified] 186 | 187 | # mikutterはuriをURI型であるとみなす 188 | hash[:original_uri] = hash[:uri] 189 | hash.delete :uri 190 | 191 | # sub_parts_client用 192 | if hash[:application] && hash[:application][:name] 193 | hash[:source] = hash[:application][:name] 194 | end 195 | 196 | # Mentionのacctにドメイン付加 197 | if hash[:mentions] 198 | hash[:mentions].each_index do |i| 199 | acct = hash[:mentions][i][:acct] 200 | hash[:mentions][i][:acct] = Account.regularize_acct_by_domain(hash[:domain], acct) 201 | end 202 | end 203 | 204 | # notification用 205 | hash[:retweet] = hash[:reblog] 206 | 207 | super hash 208 | 209 | self[:user] = self[:account] 210 | if self.reblog.is_a?(Status) && self.reblog.account.is_a?(Account) 211 | self.reblog[:user] = self.reblog.account 212 | end 213 | 214 | @emoji_score = Hash.new 215 | 216 | content = actual_status.content 217 | unless spoiler_text.empty? 218 | content = spoiler_text + "
----
" + content 219 | end 220 | @description, @score = PM::Parser.dictate_score(content, mentions: mentions, emojis: emojis, media_attachments: media_attachments) 221 | 222 | self 223 | end 224 | 225 | def inspect 226 | "worldon-status(#{description})" 227 | end 228 | 229 | def merge(domain_name, new_hash) 230 | # 取得元が発言者の所属インスタンスであれば優先する 231 | account_domain = account&.domain 232 | account_domain2 = Account.domain(new_hash[:account][:url]) 233 | if domain.nil? || domain != account_domain && domain_name == account_domain2 234 | self.id = new_hash[:id] 235 | self.domain = domain_name 236 | if (application.nil? || self[:source].nil?) && new_hash[:application] 237 | self.application = Application.new(new_hash[:application]) 238 | self[:source] = application.name 239 | end 240 | end 241 | reblogs_count = new_hash[:reblogs_count] 242 | favourites_count = new_hash[:favourites_count] 243 | pinned = new_hash[:pinned] 244 | self 245 | end 246 | 247 | def actual_status 248 | if reblog.nil? 249 | self 250 | else 251 | reblog 252 | end 253 | end 254 | 255 | def icon 256 | actual_status.account.icon 257 | end 258 | 259 | def user 260 | account 261 | end 262 | 263 | def retweet_count 264 | actual_status.reblogs_count 265 | end 266 | 267 | def favorite_count 268 | actual_status.favourites_count 269 | end 270 | 271 | def retweet? 272 | reblog.is_a? Status 273 | end 274 | 275 | def retweeted_by 276 | actual_status.reblog_status_uris.map{|pair| pair[:acct] }.compact.uniq.map{|acct| Account.findbyacct(acct) }.compact 277 | end 278 | 279 | def shared?(counterpart = nil) 280 | if counterpart.nil? 281 | counterpart = Plugin.filtering(:world_current, nil).first 282 | end 283 | if counterpart.respond_to?(:user_obj) 284 | counterpart = counterpart.user_obj 285 | end 286 | if counterpart.is_a?(Account) 287 | actual_status.retweeted_by.include?(counterpart) 288 | end 289 | end 290 | 291 | alias :retweeted? :shared? 292 | 293 | def favorited_by 294 | @favorite_accts.map{|acct| Account.findbyacct(acct) }.compact.uniq 295 | end 296 | 297 | def favorite?(counterpart = nil) 298 | if counterpart.nil? 299 | counterpart = Plugin.filtering(:world_current, nil).first 300 | end 301 | if counterpart.respond_to?(:user_obj) 302 | counterpart = counterpart.user_obj 303 | end 304 | 305 | if counterpart.is_a?(Account) 306 | @favorite_accts.include?(counterpart.idname) 307 | end 308 | end 309 | 310 | # sub_parts_client用 311 | def source 312 | actual_status.application&.name 313 | end 314 | 315 | def add_attachments(text) 316 | if media_attachments && !media_attachments.empty? 317 | media_attachments.each do |attachment| 318 | url = attachment.text_url 319 | if url.nil? 320 | url = attachment.url 321 | end 322 | if !text.include?(url.to_s) 323 | text += " #{url}" 324 | end 325 | end 326 | end 327 | text 328 | end 329 | 330 | # register reply:true用API 331 | def mentioned_by_me? 332 | !mentions.empty? && from_me? 333 | end 334 | 335 | def from_me_world 336 | world = Plugin.filtering(:world_current, nil).first 337 | return nil if (!world.respond_to?(:account) || !world.account.respond_to?(:acct)) 338 | return nil if account.acct != world.account.acct 339 | world 340 | end 341 | 342 | # register myself:true用API 343 | def from_me?(world = nil) 344 | if world 345 | if world.is_a? Plugin::Worldon::World 346 | return account.acct == world.account.acct 347 | else 348 | return false 349 | end 350 | end 351 | !!from_me_world 352 | end 353 | 354 | # 通知用 355 | # 自分へのmention 356 | def mention_to_me?(world) 357 | return false if mentions.empty? 358 | return false if (!world.respond_to?(:account) || !world.account.respond_to?(:acct)) 359 | mentions.map{|mention| mention.acct }.include?(world.account.acct) 360 | end 361 | 362 | # 自分へのreblog 363 | def reblog_to_me?(world) 364 | return false if reblog.nil? 365 | reblog.from_me?(world) 366 | end 367 | 368 | def to_me_world 369 | world = Plugin.filtering(:world_current, nil).first 370 | return nil if (!mention_to_me?(world) && !reblog_to_me?(world)) 371 | world 372 | end 373 | 374 | # mentionもしくはretweetが自分に向いている(twitter APIで言うreceiverフィールドが自分ということ) 375 | def to_me?(world = nil) 376 | if !world.nil? 377 | if world.is_a? Plugin::Worldon::World 378 | return mention_to_me?(world) || reblog_to_me?(world) 379 | else 380 | return false 381 | end 382 | end 383 | !to_me_world.nil? 384 | end 385 | 386 | # activity用 387 | def to_s 388 | description 389 | end 390 | 391 | # ふぁぼ 392 | def favorite(do_fav) 393 | world, = Plugin.filtering(:world_current, nil) 394 | if do_fav 395 | Plugin[:worldon].favorite(world, self) 396 | else 397 | Plugin[:worldon].unfavorite(world, self) 398 | end 399 | end 400 | 401 | def retweeted_statuses 402 | reblog_status_uris.map{|pair| @@status_storage[pair[:uri]] }.compact 403 | end 404 | 405 | alias :retweeted_sources :retweeted_statuses 406 | 407 | # Message#.introducer 408 | # 本当はreblogがあればreblogをreblogした最後のStatusを返す 409 | # reblogがなければselfを返す 410 | def introducer(world = nil) 411 | self 412 | end 413 | 414 | # 返信スレッド用 415 | def around(force_retrieve=false) 416 | resp = Plugin::Worldon::API.call(:get, domain, '/api/v1/statuses/' + id + '/context') 417 | return [self] if resp.nil? 418 | ancestors = Status.build(domain, resp[:ancestors]) 419 | descendants = Status.build(domain, resp[:descendants]) 420 | @ancestors = ancestors.reverse 421 | @descendants = descendants 422 | @around = ancestors + [self] + descendants 423 | end 424 | 425 | def ancestors(force_retrieve=false) 426 | resp = Plugin::Worldon::API.call(:get, domain, '/api/v1/statuses/' + id + '/context') 427 | return [self] if resp.nil? 428 | ancestors = Status.build(domain, resp[:ancestors]) 429 | @ancestors = [self] + ancestors.reverse 430 | end 431 | 432 | # 返信表示用 433 | def has_receive_message? 434 | !in_reply_to_id.nil? 435 | end 436 | 437 | # 返信表示用 438 | def replyto_source(force_retrieve=false) 439 | if domain.nil? 440 | # 何故かreplyviewerに渡されたStatusからdomainが消失することがあるので復元を試みる 441 | world, = Plugin.filtering(:worldon_current, nil) 442 | if world 443 | # 見つかったworldでstatusを取得し、id, domain, in_reply_to_idを上書きする。 444 | status = Plugin::Worldon::API.status_by_url(world.domain, world.access_token, url) 445 | if status 446 | self[:id] = status[:id] 447 | self[:domain] = world.domain 448 | self[:in_reply_to_id] = status[:in_reply_to_id] 449 | if status[:reblog] 450 | self.reblog[:id] = status[:reblog][:id] 451 | self.reblog[:domain] = world.domain 452 | self.reblog[:in_reply_to_id] = status[:reblog][:in_reply_to_id] 453 | end 454 | end 455 | end 456 | end 457 | resp = Plugin::Worldon::API.status(domain, in_reply_to_id) 458 | return nil if resp.nil? 459 | Status.build(domain, [resp.value]).first 460 | end 461 | 462 | # 返信表示用 463 | def replyto_source_d(force_retrieve=true) 464 | promise = Delayer::Deferred.new(true) 465 | Thread.new do 466 | begin 467 | result = replyto_source(force_retrieve) 468 | if result.is_a? Status 469 | promise.call(result) 470 | else 471 | promise.fail(result) 472 | end 473 | rescue Exception => e 474 | promise.fail(e) 475 | end 476 | end 477 | promise 478 | end 479 | 480 | def retweet_source(force_retrieve=false) 481 | reblog 482 | end 483 | 484 | def retweet_source_d 485 | promise = Delayer::Deferred.new(true) 486 | Thread.new do 487 | begin 488 | if reblog.is_a? Status 489 | promise.call(reblog) 490 | else 491 | promise.fail(reblog) 492 | end 493 | rescue Exception => e 494 | promise.fail(e) 495 | end 496 | end 497 | promise 498 | end 499 | 500 | def retweet_ancestors(force_retrieve=false) 501 | if reblog.is_a? Status 502 | [self, reblog] 503 | else 504 | [self] 505 | end 506 | end 507 | 508 | def rebloggable?(world = nil) 509 | !actual_status.shared?(world) && !['private', 'direct'].include?(actual_status.visibility) 510 | end 511 | 512 | # 最終更新日時を取得する 513 | def modified 514 | @value[:modified] ||= [created, *(@retweets || []).map{ |x| x.modified }].compact.max 515 | end 516 | # 最終更新日時を更新する 517 | def set_modified(time) 518 | if modified < time 519 | self[:modified] = time 520 | Plugin::call(:message_modified, self) 521 | end 522 | self 523 | end 524 | 525 | end 526 | end 527 | -------------------------------------------------------------------------------- /spell.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | Plugin.create(:worldon) do 4 | pm = Plugin::Worldon 5 | 6 | # command 7 | custom_postable = Proc.new do |opt| 8 | world, = Plugin.filtering(:world_current, nil) 9 | [:worldon, :portal].include?(world.class.slug) && opt.widget.editable? 10 | end 11 | 12 | def visibility2select(s) 13 | case s 14 | when "public" 15 | :"1public" 16 | when "unlisted" 17 | :"2unlisted" 18 | when "private" 19 | :"3private" 20 | when "direct" 21 | :"4direct" 22 | else 23 | nil 24 | end 25 | end 26 | 27 | def select2visibility(s) 28 | case s 29 | when :"1public" 30 | "public" 31 | when :"2unlisted" 32 | "unlisted" 33 | when :"3private" 34 | "private" 35 | when :"4direct" 36 | "direct" 37 | else 38 | nil 39 | end 40 | end 41 | 42 | command( 43 | :worldon_custom_post, 44 | name: 'カスタム投稿', 45 | condition: custom_postable, 46 | visible: true, 47 | icon: Skin['post.png'], 48 | role: :postbox 49 | ) do |opt| 50 | world, = Plugin.filtering(:world_current, nil) 51 | 52 | i_postbox = opt.widget 53 | postbox, = Plugin.filtering(:gui_get_gtk_widget, i_postbox) 54 | body = postbox.widget_post.buffer.text 55 | reply_to = postbox.worldon_get_reply_to 56 | 57 | dialog "カスタム投稿" do 58 | # オプションを並べる 59 | multitext "CW警告文", :spoiler_text 60 | self[:body] = body 61 | multitext "本文", :body 62 | self[:sensitive] = world.account.source.sensitive 63 | boolean "閲覧注意", :sensitive 64 | 65 | visibility_default = world.account.source.privacy 66 | if reply_to.is_a?(pm::Status) && reply_to.visibility == "direct" 67 | # 返信先がDMの場合はデフォルトでDMにする。但し編集はできるようにするため、この時点でデフォルト値を代入するのみ。 68 | visibility_default = "direct" 69 | end 70 | self[:visibility] = visibility2select(visibility_default) 71 | select "公開範囲", :visibility do 72 | option :"1public", "公開" 73 | option :"2unlisted", "未収載" 74 | option :"3private", "非公開" 75 | option :"4direct", "ダイレクト" 76 | end 77 | 78 | # mikutter-uwm-hommageの設定を勝手に持ってくる 79 | dirs = 10.times.map { |i| 80 | UserConfig["galary_dir#{i + 1}".to_sym] 81 | }.compact.select { |str| 82 | !str.empty? 83 | }.to_a 84 | 85 | fileselect "添付メディア1", :media1, shortcuts: dirs, use_preview: true 86 | fileselect "添付メディア2", :media2, shortcuts: dirs, use_preview: true 87 | fileselect "添付メディア3", :media3, shortcuts: dirs, use_preview: true 88 | fileselect "添付メディア4", :media4, shortcuts: dirs, use_preview: true 89 | end.next do |result| 90 | # 投稿 91 | # まず画像をアップロード 92 | media_ids = [] 93 | media_urls = [] 94 | (1..4).each do |i| 95 | if result[:"media#{i}"] 96 | path = Pathname(result[:"media#{i}"]) 97 | hash = pm::API.call(:post, world.domain, '/api/v1/media', world.access_token, file: path) 98 | if hash.value && hash[:error].nil? 99 | media_ids << hash[:id].to_i 100 | media_urls << hash[:text_url] 101 | else 102 | Deferred.fail(hash[:error] ? hash[:error] : 'メディアのアップロードに失敗しました') 103 | next 104 | end 105 | end 106 | end 107 | # 画像がアップロードできたらcompose spellを起動 108 | opts = { 109 | body: result[:body] 110 | } 111 | if !media_ids.empty? 112 | opts[:media_ids] = media_ids 113 | end 114 | if !result[:spoiler_text].empty? 115 | opts[:spoiler_text] = result[:spoiler_text] 116 | end 117 | opts[:sensitive] = result[:sensitive] 118 | opts[:visibility] = select2visibility(result[:visibility]) 119 | compose(world, reply_to, **opts) 120 | 121 | if Gtk::PostBox.list[0] != postbox 122 | postbox.destroy 123 | else 124 | postbox.widget_post.buffer.text = '' 125 | end 126 | end 127 | end 128 | 129 | command(:worldon_follow_user, name: 'フォローする', visible: true, role: :timeline, 130 | condition: lambda { |opt| 131 | world, = Plugin.filtering(:world_current, nil) 132 | opt.messages.any? { |m| 133 | follow?(world, m.user) 134 | } 135 | }) do |opt| 136 | world, = Plugin.filtering(:world_current, nil) 137 | next unless world 138 | 139 | opt.messages.map { |m| 140 | m.user 141 | }.each { |user| 142 | follow(world, user) 143 | } 144 | end 145 | 146 | command(:worldon_unfollow_user, name: 'フォロー解除', visible: true, role: :timeline, 147 | condition: lambda { |opt| 148 | world, = Plugin.filtering(:world_current, nil) 149 | opt.messages.any? { |m| 150 | unfollow?(world, m.user) 151 | } 152 | }) do |opt| 153 | world, = Plugin.filtering(:world_current, nil) 154 | next unless world 155 | 156 | opt.messages.map { |m| 157 | m.user 158 | }.each { |user| 159 | unfollow(world, user) 160 | } 161 | end 162 | 163 | command(:worldon_mute_user, name: 'ミュートする', visible: true, role: :timeline, 164 | condition: lambda { |opt| 165 | world, = Plugin.filtering(:world_current, nil) 166 | opt.messages.any? { |m| mute_user?(world, m.user) } 167 | }) do |opt| 168 | world, = Plugin.filtering(:world_current, nil) 169 | next unless world 170 | users = opt.messages.map { |m| m.user }.uniq 171 | dialog "ミュートする" do 172 | label "以下のユーザーをミュートしますか?" 173 | users.each { |user| 174 | link user 175 | } 176 | end.next do 177 | users.each { |user| 178 | mute_user(world, user) 179 | } 180 | end 181 | end 182 | 183 | command(:worldon_block_user, name: 'ブロックする', visible: true, role: :timeline, 184 | condition: lambda { |opt| 185 | world, = Plugin.filtering(:world_current, nil) 186 | opt.messages.any? { |m| block_user?(world, m.user) } 187 | }) do |opt| 188 | world, = Plugin.filtering(:world_current, nil) 189 | next unless world 190 | users = opt.messages.map { |m| m.user }.uniq 191 | dialog "ブロックする" do 192 | label "以下のユーザーをブロックしますか?" 193 | users.each { |user| 194 | link user 195 | } 196 | end.next do 197 | users.each { |user| 198 | block_user(world, user) 199 | } 200 | end 201 | end 202 | 203 | command(:worldon_report_status, name: '通報する', visible: true, role: :timeline, 204 | condition: lambda { |opt| 205 | world, = Plugin.filtering(:world_current, nil) 206 | opt.messages.any? { |m| report_for_spam?(world, m) } 207 | }) do |opt| 208 | world, = Plugin.filtering(:world_current, nil) 209 | next unless world 210 | dialog "通報する" do 211 | error_msg = nil 212 | while true 213 | label "以下のトゥートを #{world.domain} インスタンスの管理者に通報しますか?" 214 | opt.messages.each { |message| 215 | link message 216 | } 217 | multitext "コメント(1000文字以内) ※必須", :comment 218 | label error_msg if error_msg 219 | 220 | result = await_input 221 | error_msg = "コメントを入力してください。" if (result[:comment].nil? || result[:comment].empty?) 222 | error_msg = "コメントが長すぎます(#{result[:comment].to_s.size}文字)" if result[:comment].to_s.size > 1000 223 | break unless error_msg 224 | end 225 | 226 | label "しばらくお待ち下さい..." 227 | 228 | results = opt.messages.select { |message| 229 | message.class.slug == :worldon_status 230 | }.map { |message| 231 | message.reblog ? message.reblog : message 232 | }.sort_by { |message| 233 | message.account.acct 234 | }.chunk { |message| 235 | message.account.acct 236 | }.each { |acct, messages| 237 | world.report_for_spam(messages, result[:comment]) 238 | } 239 | 240 | label "完了しました。" 241 | end 242 | end 243 | 244 | command(:worldon_pin_message, name: 'ピン留めする', visible: true, role: :timeline, 245 | condition: lambda { |opt| 246 | world, = Plugin.filtering(:world_current, nil) 247 | opt.messages.any? { |m| pin_message?(world, m) } 248 | }) do |opt| 249 | world, = Plugin.filtering(:world_current, nil) 250 | next unless world 251 | 252 | opt.messages.select{ |m| 253 | pin_message?(world, m) 254 | }.each { |status| 255 | world.pin(status) 256 | } 257 | end 258 | 259 | command(:worldon_unpin_message, name: 'ピン留めを解除する', visible: true, role: :timeline, 260 | condition: lambda { |opt| 261 | world, = Plugin.filtering(:world_current, nil) 262 | opt.messages.any? { |m| unpin_message?(world, m) } 263 | }) do |opt| 264 | world, = Plugin.filtering(:world_current, nil) 265 | next unless world 266 | 267 | opt.messages.select{ |m| 268 | unpin_message?(world, m) 269 | }.each { |status| 270 | world.unpin(status) 271 | } 272 | end 273 | 274 | command(:worldon_edit_list_membership, name: 'リストへの追加・削除', visible: true, role: :timeline, 275 | condition: lambda { |opt| 276 | world, = Plugin.filtering(:world_current, nil) 277 | [:worldon, :portal].include?(world.class.slug) 278 | }) do |opt| 279 | world, = Plugin.filtering(:world_current, nil) 280 | next unless world 281 | 282 | user = opt.messages.first&.user 283 | next unless user 284 | 285 | lists = world.get_lists!.inject(Hash.new) do |h, l| 286 | key = l[:id].to_sym 287 | val = l[:title] 288 | h[key] = val 289 | h 290 | end 291 | 292 | user_id = pm::API.get_local_account_id(world, user) 293 | result = pm::API.call(:get, world.domain, "/api/v1/accounts/#{user_id}/lists", world.access_token) 294 | membership = result.value.to_a.inject(Hash.new) do |h, l| 295 | key = l[:id].to_sym 296 | val = l[:title] 297 | h[key] = val 298 | h 299 | end 300 | 301 | 302 | dialog "リストへの追加・削除" do 303 | self[:lists] = membership.keys 304 | multiselect "所属させるリストを選択してください", :lists do 305 | lists.keys.each do |k| 306 | option(k, lists[k]) 307 | end 308 | end 309 | end.next do |result| 310 | selected_ids = result[:lists] 311 | selected_ids.each do |list_id| 312 | unless membership[list_id] 313 | # 追加 314 | pm::API.call(:post, world.domain, "/api/v1/lists/#{list_id}/accounts", world.access_token, account_ids: [user_id]) 315 | end 316 | end 317 | membership.keys.each do |list_id| 318 | unless selected_ids.include?(list_id) 319 | # 削除 320 | pm::API.call(:delete, world.domain, "/api/v1/lists/#{list_id}/accounts", world.access_token, account_ids: [user_id]) 321 | end 322 | end 323 | end 324 | end 325 | 326 | 327 | # spell系 328 | 329 | # 投稿 330 | defspell(:compose, :worldon) do |world, body:, **opts| 331 | if opts[:visibility].nil? 332 | opts.delete :visibility 333 | else 334 | opts[:visibility] = opts[:visibility].to_s 335 | end 336 | 337 | if opts[:sensitive].nil? && opts[:media_ids].nil? && opts[:spoiler_text].nil? 338 | opts[:sensitive] = false; 339 | end 340 | 341 | result = world.post(message: body, **opts) 342 | if result.nil? 343 | warn "投稿に失敗したかもしれません" 344 | $stdout.flush 345 | nil 346 | else 347 | new_status = pm::Status.build(world.domain, [result.value]).first 348 | Plugin.call(:posted, world, [new_status]) if new_status 349 | Plugin.call(:update, world, [new_status]) if new_status 350 | new_status 351 | end 352 | end 353 | 354 | memoize def media_tmp_dir 355 | path = Pathname(Environment::TMPDIR) / 'worldon' / 'media' 356 | FileUtils.mkdir_p(path.to_s) 357 | path 358 | end 359 | 360 | defspell(:compose, :worldon, :photo) do |world, photo, body:, **opts| 361 | photo.download.next{|photo| 362 | ext = photo.uri.path.split('.').last || 'png' 363 | tmp_name = Digest::MD5.hexdigest(photo.uri.to_s) + ".#{ext}" 364 | tmp_path = media_tmp_dir / tmp_name 365 | file_put_contents(tmp_path, photo.blob) 366 | hash = pm::API.call(:post, world.domain, '/api/v1/media', world.access_token, file: tmp_path.to_s) 367 | if hash 368 | media_id = hash[:id] 369 | compose(world, body: body, media_ids: [media_id], **opts) 370 | end 371 | } 372 | end 373 | 374 | defspell(:compose, :worldon, :worldon_status) do |world, status, body:, **opts| 375 | if opts[:visibility].nil? 376 | opts.delete :visibility 377 | if status.visibility == "direct" 378 | # 返信先がDMの場合はデフォルトでDMにする。但し呼び出し元が明示的に指定してきた場合はそちらを尊重する。 379 | opts[:visibility] = "direct" 380 | end 381 | else 382 | opts[:visibility] = opts[:visibility].to_s 383 | end 384 | if opts[:sensitive].nil? && opts[:media_ids].nil? && opts[:spoiler_text].nil? 385 | opts[:sensitive] = false; 386 | end 387 | 388 | result = world.post(to: status, message: body, **opts) 389 | if result.nil? 390 | warn "投稿に失敗したかもしれません" 391 | $stdout.flush 392 | nil 393 | else 394 | new_status = pm::Status.build(world.domain, [result.value]).first 395 | Plugin.call(:posted, world, [new_status]) if new_status 396 | Plugin.call(:update, world, [new_status]) if new_status 397 | new_status 398 | end 399 | end 400 | 401 | defspell(:destroy, :worldon, :worldon_status, condition: -> (world, status) { 402 | world.account.acct == status.actual_status.account.acct 403 | }) do |world, status| 404 | status_id = pm::API.get_local_status_id(world, status.actual_status) 405 | if status_id 406 | ret = pm::API.call(:delete, world.domain, "/api/v1/statuses/#{status_id}", world.access_token) 407 | Plugin.call(:destroyed, status.actual_status) 408 | status.actual_status 409 | end 410 | end 411 | 412 | # ふぁぼ 413 | defspell(:favorite, :worldon, :worldon_status, 414 | condition: -> (world, status) { !status.actual_status.favorite?(world) } 415 | ) do |world, status| 416 | Thread.new { 417 | status_id = pm::API.get_local_status_id(world, status.actual_status) 418 | if status_id 419 | Plugin.call(:before_favorite, world, world.account, status) 420 | ret = pm::API.call(:post, world.domain, '/api/v1/statuses/' + status_id.to_s + '/favourite', world.access_token) 421 | if ret.nil? || ret[:error] 422 | Plugin.call(:fail_favorite, world, world.account, status) 423 | else 424 | status.actual_status.favourited = true 425 | status.actual_status.favorite_accts << world.account.acct 426 | Plugin.call(:favorite, world, world.account, status) 427 | end 428 | end 429 | } 430 | end 431 | 432 | defspell(:favorited, :worldon, :worldon_status, 433 | condition: -> (world, status) { status.actual_status.favorite?(world) } 434 | ) do |world, status| 435 | Delayer::Deferred.new.next { 436 | status.actual_status.favorite?(world) 437 | } 438 | end 439 | 440 | defspell(:unfavorite, :worldon, :worldon_status, condition: -> (world, status) { status.favorite?(world) }) do |world, status| 441 | Thread.new { 442 | status_id = pm::API.get_local_status_id(world, status.actual_status) 443 | if status_id 444 | ret = pm::API.call(:post, world.domain, '/api/v1/statuses/' + status_id.to_s + '/unfavourite', world.access_token) 445 | if ret.nil? || ret[:error] 446 | warn "[worldon] failed to unfavourite: #{ret}" 447 | else 448 | status.actual_status.favourited = false 449 | status.actual_status.favorite_accts.delete(world.account.acct) 450 | Plugin.call(:favorite, world, world.account, status) 451 | end 452 | status.actual_status 453 | end 454 | } 455 | end 456 | 457 | # ブースト 458 | defspell(:share, :worldon, :worldon_status, 459 | condition: -> (world, status) { status.actual_status.rebloggable?(world) } 460 | ) do |world, status| 461 | world.reblog(status.actual_status).next{|shared| 462 | Plugin.call(:posted, world, [shared]) 463 | Plugin.call(:update, world, [shared]) 464 | } 465 | end 466 | 467 | defspell(:shared, :worldon, :worldon_status, 468 | condition: -> (world, status) { status.actual_status.shared?(world) } 469 | ) do |world, status| 470 | Delayer::Deferred.new.next { 471 | status.actual_status.shared?(world) 472 | } 473 | end 474 | 475 | defspell(:destroy_share, :worldon, :worldon_status, condition: -> (world, status) { status.actual_status.shared?(world) }) do |world, status| 476 | Thread.new { 477 | status_id = pm::API.get_local_status_id(world, status.actual_status) 478 | if status_id 479 | ret = pm::API.call(:post, world.domain, '/api/v1/statuses/' + status_id.to_s + '/unreblog', world.access_token) 480 | reblog = nil 481 | if ret.nil? || ret[:error] 482 | warn "[worldon] failed to unreblog: #{ret}" 483 | else 484 | status.actual_status.reblogged = false 485 | reblog = status.actual_status.retweeted_statuses.select{|s| s.account.acct == world.user_obj.acct }.first 486 | status.actual_status.reblog_status_uris.delete_if {|pair| pair[:acct] == world.user_obj.acct } 487 | if reblog 488 | Plugin.call(:destroyed, [reblog]) 489 | end 490 | end 491 | reblog 492 | end 493 | } 494 | end 495 | 496 | # プロフィール更新系 497 | update_profile_block = Proc.new do |world, **opts| 498 | world.update_profile(**opts) 499 | end 500 | 501 | defspell(:update_profile, :worldon, &update_profile_block) 502 | defspell(:update_profile_name, :worldon, &update_profile_block) 503 | defspell(:update_profile_biography, :worldon, &update_profile_block) 504 | defspell(:update_profile_icon, :worldon, :photo) do |world, photo| 505 | update_profile_block.call(world, icon: photo) 506 | end 507 | defspell(:update_profile_header, :worldon, :photo) do |world, photo| 508 | update_profile_block.call(world, header: photo) 509 | end 510 | 511 | command( 512 | :worldon_update_profile, 513 | name: 'プロフィール変更', 514 | condition: -> (opt) { 515 | world = Plugin.filtering(:world_current, nil).first 516 | [:worldon, :portal].include?(world.class.slug) 517 | }, 518 | visible: true, 519 | role: :postbox 520 | ) do |opt| 521 | world = Plugin.filtering(:world_current, nil).first 522 | 523 | profiles = Hash.new 524 | profiles[:name] = world.account.display_name 525 | profiles[:biography] = world.account.source.note 526 | profiles[:locked] = world.account.locked 527 | profiles[:bot] = world.account.bot 528 | 529 | dialog "プロフィール変更" do 530 | self[:name] = profiles[:name] 531 | self[:biography] = profiles[:biography] 532 | self[:locked] = profiles[:locked] 533 | self[:bot] = profiles[:bot] 534 | 535 | input '表示名', :name 536 | multitext 'プロフィール', :biography 537 | photoselect 'アイコン', :icon 538 | photoselect 'ヘッダー', :header 539 | boolean '承認制アカウントにする', :locked 540 | boolean 'これは BOT アカウントです', :bot 541 | end.next do |result| 542 | diff = Hash.new 543 | diff[:name] = result[:name] if (result[:name] && result[:name].size > 0 && profiles[:name] != result[:name]) 544 | diff[:biography] = result[:biography] if (result[:biography] && result[:biography].size > 0 && profiles[:biography] != result[:biography]) 545 | diff[:locked] = result[:locked] if profiles[:locked] != result[:locked] 546 | diff[:bot] = result[:bot] if profiles[:bot] != result[:bot] 547 | diff[:icon] = Pathname(result[:icon]) if result[:icon] 548 | diff[:header] = Pathname(result[:header]) if result[:header] 549 | next if diff.empty? 550 | 551 | world.update_profile(**diff) 552 | end 553 | end 554 | 555 | # 検索 556 | intent :worldon_tag, label: "Mastodonハッシュタグ(Worldon)" do |token| 557 | Plugin.call(:search_start, "##{token.model.name}") 558 | end 559 | 560 | # アカウント 561 | intent :worldon_account, label: "Mastodonアカウント(Worldon)" do |token| 562 | Plugin.call(:worldon_account_timeline, token.model) 563 | end 564 | 565 | on_worldon_account_timeline do |account| 566 | acct, domain = account.acct.split('@') 567 | tl_slug = :"worldon-account-timeline_#{acct}@#{domain}" 568 | tab :"worldon-account-tab_#{acct}@#{domain}" do |i_tab| 569 | set_icon account.icon 570 | set_deletable true 571 | temporary_tab 572 | timeline(tl_slug) do 573 | order do |message| 574 | ord = message.created.to_i 575 | if message.is_a? pm::AccountProfile 576 | ord = [5000000000000000, GLib::MAXLONG].min 577 | elsif message.respond_to?(:pinned?) && message.pinned? 578 | bias = 66200000000000 579 | ord = [ord + bias, GLib::MAXLONG - 1].min 580 | end 581 | ord 582 | end 583 | end 584 | end 585 | timeline(tl_slug).active! 586 | profile = pm::AccountProfile.new(account: account) 587 | timeline(tl_slug) << profile 588 | 589 | Thread.new do 590 | world, = Plugin.filtering(:world_current, nil) 591 | if [:worldon, :portal].include? world.class.slug 592 | account_id = pm::API.get_local_account_id(world, account) 593 | 594 | res = pm::API.call(:get, world.domain, "/api/v1/accounts/#{account_id}/statuses?pinned=true", world.access_token) 595 | if res.value 596 | timeline(tl_slug) << pm::Status.build(world.domain, res.value.map{|record| 597 | record[:pinned] = true 598 | record 599 | }) 600 | end 601 | 602 | res = pm::API.call(:get, world.domain, "/api/v1/accounts/#{account_id}/statuses", world.access_token) 603 | if res.value 604 | timeline(tl_slug) << pm::Status.build(world.domain, res.value) 605 | end 606 | 607 | next if domain == world.domain 608 | end 609 | 610 | headers = { 611 | 'Accept' => 'application/activity+json' 612 | } 613 | res = pm::API.call(:get, domain, "/users/#{acct}/outbox?page=true", nil, {}, headers) 614 | next unless res[:orderedItems] 615 | 616 | res[:orderedItems].map do |record| 617 | case record[:type] 618 | when "Create" 619 | # トゥート 620 | record[:object][:url] 621 | when "Announce" 622 | # ブースト 623 | pm::Status::TOOT_ACTIVITY_URI_RE.match(record[:atomUri]) do |m| 624 | "https://#{m[:domain]}/@#{m[:acct]}/#{m[:status_id]}" 625 | end 626 | end 627 | end.compact.each do |url| 628 | status = pm::Status.findbyurl(url) || pm::Status.fetch(url) 629 | timeline(tl_slug) << status if status 630 | end 631 | end 632 | end 633 | 634 | defspell(:search, :worldon) do |world, **opts| 635 | count = [opts[:count], 40].min 636 | q = opts[:q] 637 | if q.start_with? '#' 638 | q = URI.encode_www_form_component(q[1..-1]) 639 | resp = Plugin::Worldon::API.call(:get, world.domain, "/api/v1/timelines/tag/#{q}", world.access_token, limit: count) 640 | return nil if resp.nil? 641 | resp = resp.to_a 642 | else 643 | resp = Plugin::Worldon::API.call(:get, world.domain, '/api/v2/search', world.access_token, q: q) 644 | return nil if resp.nil? 645 | resp = resp[:statuses] 646 | end 647 | Plugin::Worldon::Status.build(world.domain, resp) 648 | end 649 | 650 | defspell(:follow, :worldon, :worldon_account, 651 | condition: -> (world, account) { !world.following?(account.acct) } 652 | ) do |world, account| 653 | world.follow(account) 654 | end 655 | 656 | defspell(:unfollow, :worldon, :worldon_account, 657 | condition: -> (world, account) { world.following?(account.acct) } 658 | ) do |world, account| 659 | world.unfollow(account) 660 | end 661 | 662 | defspell(:following, :worldon, :worldon_account, 663 | condition: -> (world, account) { true } 664 | ) do |world, account| 665 | world.following?(account) 666 | end 667 | 668 | defspell(:mute_user, :worldon, :worldon_account, 669 | condition: -> (world, account) { !Plugin::Worldon::Status.muted?(account.acct) } 670 | ) do |world, account| 671 | world.mute(account) 672 | end 673 | 674 | defspell(:unmute_user, :worldon, :worldon_account, 675 | condition: -> (world, account) { Plugin::Worldon::Status.muted?(account.acct) } 676 | ) do |world, account| 677 | world.unmute(account) 678 | end 679 | 680 | defspell(:block_user, :worldon, :worldon_account, 681 | condition: -> (world, account) { !world.block?(account.acct) } 682 | ) do |world, account| 683 | world.block(account) 684 | end 685 | 686 | defspell(:unblock_user, :worldon, :worldon_account, 687 | condition: -> (world, account) { world.block?(account.acct) } 688 | ) do |world, account| 689 | world.unblock(account) 690 | end 691 | 692 | defspell(:report_for_spam, :worldon, :worldon_status) do |world, status, comment: raise| 693 | world.report_for_spam([status], comment) 694 | end 695 | 696 | defspell(:report_for_spam, :worldon) do |world, messages:, comment: raise| 697 | world.report_for_spam(messages, comment) 698 | end 699 | 700 | defspell(:pin_message, :worldon, :worldon_status, 701 | condition: -> (world, status) { 702 | world.account.acct == status.account.acct && !status.pinned? 703 | # 自分のStatusが(ピン留め状態が不正確になりうるタイミングで)他インスタンスから取得されることはまずないと仮定している 704 | } 705 | ) do |world, status| 706 | world.pin(status) 707 | end 708 | 709 | defspell(:unpin_message, :worldon, :worldon_status, 710 | condition: -> (world, status) { 711 | world.account.acct == status.account.acct && status.pinned? 712 | # 自分のStatusが(ピン留め状態が不正確になりうるタイミングで)他インスタンスから取得されることはまずないと仮定している 713 | } 714 | ) do |world, status| 715 | world.unpin(status) 716 | end 717 | 718 | end 719 | --------------------------------------------------------------------------------