├── 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!^
|
|[^<]*|?span[^>]*>!, '')
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 |
--------------------------------------------------------------------------------