Merge branch 'master' into greenkeeper/@types/rimraf-2.0.2

This commit is contained in:
こぴなたみぽ 2017-11-06 19:11:23 +09:00 committed by GitHub
commit cb7e70dee3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
204 changed files with 9327 additions and 3339 deletions

1
.gitignore vendored
View file

@ -3,6 +3,7 @@
/node_modules
/built
/uploads
/data
npm-debug.log
*.pem
run.bat

View file

@ -1,10 +1,14 @@
# travis file
# https://docs.travis-ci.com/user/customizing-the-build
branches:
except:
- release
language: node_js
node_js:
- 7.10.0
- 8.4.0
env:
- CXX=g++-4.8 NODE_ENV=production

View file

@ -6,3 +6,5 @@
!/tools
!/elasticsearch
!/package.json
!/.travis.yml
!/appveyor.yml

View file

@ -1,7 +1,172 @@
ChangeLog
=========
ChangeLog (Release Notes)
=========================
主に notable な changes を書いていきます
2807 (2017/11/02)
-----------------
* いい感じに
2805 (2017/11/02)
-----------------
* いい感じに
2801 (2017/11/01)
-----------------
* チャンネルのWatch実装
2799 (2017/11/01)
-----------------
* いい感じに
2795 (2017/11/01)
-----------------
* いい感じに
2793 (2017/11/01)
-----------------
* なんか
2783 (2017/11/01)
-----------------
* なんか
2777 (2017/11/01)
-----------------
* 細かいブラッシュアップ
2775 (2017/11/01)
-----------------
* Fix: バグ修正
2769 (2017/11/01)
-----------------
* New: チャンネルシステム
2752 (2017/10/30)
-----------------
* New: 未読の通知がある場合アイコンを表示するように
2747 (2017/10/25)
-----------------
* Fix: 非ログイン状態ですべてのページが致命的な問題を発生させる (#89)
2742 (2017/10/25)
-----------------
* New: トラブルシューティングを実装するなど
2735 (2017/10/22)
-----------------
* New: モバイル版からでもクライアントバージョンを確認できるように
2732 (2017/10/22)
-----------------
* 依存関係の更新など
2584 (2017/09/08)
-----------------
* New: ユーザーページによく使うドメインを表示 (#771)
* New: よくリプライするユーザーをユーザーページに表示 (#770)
2566 (2017/09/07)
-----------------
* New: 投稿することの多いキーワードをユーザーページに表示する (#768)
* l10n
* デザインの修正
2544 (2017/09/06)
-----------------
* 投稿のカテゴリに関する実験的な実装
* l10n
* ユーザビリティの向上
2520 (2017/08/30)
-----------------
* デザインの調整
2518 (2017/08/30)
-----------------
* Fix: モバイル版のタイムラインからリアクションやメニューを開けない
* デザインの調整
2515 (2017/08/30)
-----------------
* New: 投稿のピン留め (#746)
* New: モバイル版のユーザーページに知り合いのフォロワーを表示するように
* New: ホームストリームにメッセージを流すことでlast_used_atを更新できるようにする (#745)
* その他細かな修正
2508 (2017/08/30)
-----------------
* New: モバイル版のユーザーページのアクティビティチャートを変更
* New: モバイル版のユーザーページに最終ログイン日時を表示するように
* デザインの調整
2503 (2017/08/30)
-----------------
* デザインの調整
2502 (2017/08/30)
-----------------
* デザインの修正・調整
2501 (2017/08/30)
-----------------
* New: モバイルのユーザーページを刷新
2498 (2017/08/29)
-----------------
* Fix: repostのborder-radiusが効いていない (#743)
* テーマカラーを赤に戻してみた
* ユーザビリティの向上
* デザインの調整
2493-2 (2017/08/29)
-------------------
* デザインの修正
2493 (2017/08/29)
-----------------
* デザインの変更など
2491 (2017/08/29)
-----------------
* デザインの修正と調整
2489 (2017/08/29)
-----------------
* ユーザビリティの向上
* デザインの調整
2487 (2017/08/29)
-----------------
* New: パスワードを変更する際に新しいパスワードを二度入力させる (#739)
* New: ドナーを表示する (#738)
* Fix: 投稿のリンクが機能していない問題を修正
* Fix: アカウント作成フォームのユーザーページURLプレビューが正しく機能していなかった問題を修正
* l10n
* デザインの調整
2470 (2017/08/29)
-----------------
* New: トークンを再生成できるように (#497)
* New: パスワードを変更する機能 (#364)
2461 (2017/08/28)
-----------------
* Fix: モバイル版からアバターとバナーの設定を行えなかった問題を修正
* デザインの修正
2458 (2017/08/28)
-----------------
* New: モバイル版からプロフィールを設定できるように
* New: モバイル版からサインアウトを行えるように
* New: 投稿ページに次の投稿/前の投稿リンクを作成 (#734)
* New: タイムラインの投稿をダブルクリックすることで詳細な情報が見れるように
* Fix: モバイル版でおすすめユーザーをフォローしてもタイムラインが更新されない (#736)
* Fix: モバイル版で設定にアクセスできない
* デザインの調整
* 依存関係の更新
2380
----
アプリケーションが作れない問題を修正

19
DONORS.md Normal file
View file

@ -0,0 +1,19 @@
DONORS
======
(no particular order)
* らふぁ
* 俺様
* なぎうり
* スルメ https://surume.tk/
:heart: Thanks for donating, guys!
---
Although you donated, you are not listed here? please contact to us!
If you want to donate to Misskey, please get in touch with [@syuilo][syuilo-link].
[syuilo-link]: https://syuilo.com

View file

@ -17,7 +17,7 @@ Key features
* Automatically updated timeline
* Private messages
* Free 1GB storage for each all users
* Mobile device support (smartphone, tablet, etc)
* Machine learning
* Web API for third-party applications
* No ads
@ -25,24 +25,23 @@ and more! You can touch with your own eyes at https://misskey.xyz/.
Setup and Installation
----------------------------------------------------------------
Please see [Setup and installation guide](./docs/setup.en.md).
If you want to run your own instance of Misskey,
please see [Setup and installation guide](./docs/setup.en.md).
Contribution
----------------------------------------------------------------
Please see [Contribution guide](./CONTRIBUTING.md).
Release Notes
----------------------------------------------------------------
Please see [ChangeLog](./CHANGELOG.md).
Sponsors & Backers
----------------------------------------------------------------
Misskey have no 100+ GitHub stars currently. However, donation are always welcome!
If you want to donate to Misskey, please get in touch with [@syuilo][syuilo-link].
Collaborators
----------------------------------------------------------------
| ![syuilo][syuilo-icon] | ![Morisawa Aya][ayamorisawa-icon] | ![otofune][otofune-icon] |
|------------------------|-----------------------------------|---------------------------------|
| [syuilo][syuilo-link] | [Aya Morisawa][ayamorisawa-link] | [otofune][otofune-link] |
[List of all contributors](https://github.com/syuilo/misskey/graphs/contributors)
**Note:** When you donate to Misskey, your name will be displayed in [donors](./DONORS.md).
Copyright
----------------------------------------------------------------
@ -61,7 +60,3 @@ Misskey is an open-source software licensed under [The MIT License](LICENSE).
<!-- Collaborators Info -->
[syuilo-link]: https://syuilo.com
[syuilo-icon]: https://avatars2.githubusercontent.com/u/4439005?v=3&s=70
[ayamorisawa-link]: https://github.com/ayamorisawa
[ayamorisawa-icon]: https://avatars0.githubusercontent.com/u/10798641?v=3&s=70
[otofune-link]: https://github.com/otofune
[otofune-icon]: https://avatars0.githubusercontent.com/u/15062473?v=3&s=70

View file

@ -1,9 +1,13 @@
# appveyor file
# http://www.appveyor.com/docs/appveyor-yml
branches:
except:
- release
environment:
matrix:
- nodejs_version: 7.10.0
- nodejs_version: 8.4.0
build: off

22
docs/backup.md Normal file
View file

@ -0,0 +1,22 @@
How to backup your Misskey
==========================
Make sure **mongodb-tools** installed.
---
In your shell:
``` shell
$ mongodump --archive=db-backup
```
For details, plese see [mongodump docs](https://docs.mongodb.com/manual/reference/program/mongodump/).
Restore
-------
``` shell
$ mongorestore --archive=db-backup
```
For details, please see [mongorestore docs](https://docs.mongodb.com/manual/reference/program/mongorestore/).

View file

@ -25,6 +25,7 @@ Note that Misskey uses following subdomains:
* **api**.*{primary domain}*
* **auth**.*{primary domain}*
* **about**.*{primary domain}*
* **ch**.*{primary domain}*
* **stats**.*{primary domain}*
* **status**.*{primary domain}*
* **dev**.*{primary domain}*

View file

@ -26,6 +26,7 @@ Misskeyは以下のサブドメインを使います:
* **api**.*{primary domain}*
* **auth**.*{primary domain}*
* **about**.*{primary domain}*
* **ch**.*{primary domain}*
* **stats**.*{primary domain}*
* **status**.*{primary domain}*
* **dev**.*{primary domain}*

View file

@ -1,4 +1,6 @@
common:
misskey: "Note everything and share it others using Misskey."
time:
unknown: "unknown"
future: "future"
@ -22,12 +24,21 @@ common:
confused: "Confused"
pudding: "Pudding"
post_categories:
music: "Music"
game: "Video Game"
anime: "Anime"
it: "IT"
gadgets: "Gadgets"
photography: "Photography"
input-message-here: "Enter message here"
send: "Send"
delete: "Delete"
loading: "Loading"
ok: "OK"
update-available: "New version of Misskey is now available({newer}, current is {current}). Reload page to apply update."
my-token-regenerated: "Your token is just regenerated, so you will signout."
tags:
mk-messaging-form:
@ -55,8 +66,27 @@ common:
mk-error:
title: "Unable to connect to the server"
description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。"
description: "There is a problem with Internet connection, or the server may be down or maintaining. Please {try again} later."
thanks: "Thank you for using Misskey."
troubleshoot: "Troubleshoot"
troubleshooter:
title: "TroubleShooting"
network: "Network connection"
checking-network: "Checking network connection"
internet: "Internet connection"
checking-internet: "Checking internet connection"
server: "Server connection"
checking-server: "Checking server connection"
finding: "Finding a problem"
no-network: "There is no Network connection"
no-network-desc: "Please make sure you are connected to the Network."
no-internet: "There is no Internet connection"
no-internet-desc: "Please make sure you are connected to the Internet."
no-server: "Unable to connect to the server"
no-server-desc: "The network connection of your PC is normal, but you could not connect to Misskey's server. There is a possibility that the server is down or maintaining, please try to access it again after a while."
success: "Successfully connect to the Misskey's server"
success-desc: "It seems to be able to connect normally. Please reload the page."
mk-forkit:
open-github-link: "View source on Github"
@ -76,6 +106,13 @@ common:
show-result: "Show result"
voted: "Voted"
mk-post-menu:
pin: "Pin"
pinned: "Pinned"
select: "Select category"
categorize: "Accept"
categorized: "Category reported. Thank you!"
mk-reaction-picker:
choose-reaction: "Pick your reaction"
@ -127,8 +164,24 @@ common:
mk-uploader:
waiting: "Waiting"
ch:
tags:
mk-index:
new: "Create new channel"
channel-title: "Channel title"
mk-channel-form:
textarea: "Write here"
upload: "Upload"
drive: "Drive"
post: "Do"
posting: "Doing"
desktop:
tags:
mk-api-info:
regenerate-token: "Please enter the password"
mk-drive-browser-base-contextmenu:
create-folder: "Create a folder"
upload: "Upload a file"
@ -189,9 +242,19 @@ desktop:
mk-drive-browser-nav-folder:
drive: "Drive"
mk-nav-home-widget:
about: "About"
stats: "Stats"
status: "Status"
wiki: "Wiki"
donors: "Donors"
repository: "Repository"
develop: "Developers"
mk-ui-header-nav:
home: "Home"
messaging: "Messages"
ch: "Channels"
info: "News"
mk-ui-header-search:
@ -204,6 +267,14 @@ desktop:
settings: "Settings"
signout: "Sign out"
mk-password-setting:
reset: "Change your password"
enter-current-password: "Enter the current password"
enter-new-password: "Enter the new password"
enter-new-password-again: "Enter the new password again"
not-match: "New password not matched"
changed: "Password updated successfully"
mk-post-form:
post-placeholder: "What's happening?"
reply-placeholder: "Reply to this post..."
@ -231,6 +302,13 @@ desktop:
attaches: "{} media attached"
uploading-media: "Uploading {} media"
mk-post-page:
prev: "Previous post"
next: "Next post"
mk-settings:
password: "Password"
mk-timeline-post:
reposted-by: "Reposted by {}"
reply: "Reply"
@ -289,6 +367,9 @@ desktop:
mobile:
tags:
mk-selectdrive-page:
select-file: "Select file(s)"
mk-drive-file-viewer:
download: "Download"
rename: "Rename"
@ -325,19 +406,46 @@ mobile:
mk-notifications-page:
notifications: "Notifications"
read-all: "Are you sure you want to mark all unread notifications as read?"
mk-post-page:
submit: "Post"
title: "Post"
prev: "Previous post"
next: "Next post"
mk-search-page:
search: "Search"
mk-settings:
signed-in-as: "Signed in as {}"
mk-settings-page:
profile: "Profile"
applications: "Applications"
twitter-integration: "Twitter integration"
signin-history: "Sign in history"
api: "API"
link: "MisskeyLink"
settings: "Settings"
signout: "Sign out"
mk-profile-setting-page:
title: "Profile Settings"
mk-profile-setting:
will-be-published: "These profiles will be published."
name: "Name"
location: "Location"
description: "Description"
birthday: "Birthday"
avatar: "Avatar"
banner: "Banner"
avatar-saved: "Avatar updated successfully"
banner-saved: "Banner updated successfully"
set-avatar: "Choose an avatar"
set-banner: "Choose a banner"
save: "Save"
saved: "Profile updated successfully"
mk-user-followers-page:
followers-of: "Followers of {}"
@ -400,6 +508,7 @@ mobile:
home: "Home"
notifications: "Notifications"
messaging: "Messages"
ch: "Channels"
drive: "Drive"
settings: "Settings"
about: "About Misskey"
@ -416,12 +525,46 @@ mobile:
no-posts-with-media: "There is no posts with media"
mk-user:
is-followed: "Followed you"
follows-you: "Follows you"
following: "Following"
followers: "Followers"
posts: "Timeline"
posts: "Posts"
overview: "Overview"
timeline: "Timeline"
media: "Media"
mk-user-overview:
recent-posts: "Recent posts"
images: "Images"
activity: "Activity"
keywords: "Keywords"
domains: "Domains"
frequently-replied-users: "Frequently talking users"
followers-you-know: "Followers you know"
last-used-at: "Last used at"
mk-user-overview-posts:
loading: "Loading"
no-posts: "No posts"
mk-user-overview-photos:
loading: "Loading"
no-photos: "No photos"
mk-user-overview-keywords:
no-keywords: "No keywords"
mk-user-overview-domains:
no-domains: "No domains"
mk-user-overview-frequently-replied-users:
loading: "Loading"
no-users: "No users"
mk-user-overview-followers-you-know:
loading: "Loading"
no-users: "No users"
mk-users-list:
all: "All"
known: "You know"

View file

@ -1,4 +1,6 @@
common:
misskey: "Misskeyに何でも投稿して皆と共有しましょう。"
time:
unknown: "なぞのじかん"
future: "未来"
@ -22,12 +24,21 @@ common:
confused: "こまこまのこまり"
pudding: "Pudding"
post_categories:
music: "音楽"
game: "ゲーム"
anime: "アニメ"
it: "IT"
gadgets: "ガジェット"
photography: "写真"
input-message-here: "ここにメッセージを入力"
send: "送信"
delete: "削除"
loading: "読み込み中"
ok: "わかった"
update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。"
my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
tags:
mk-messaging-form:
@ -55,8 +66,27 @@ common:
mk-error:
title: "サーバーに接続できません"
description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。"
description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。"
thanks: "いつもMisskeyをご利用いただきありがとうございます。"
troubleshoot: "トラブルシュート"
troubleshooter:
title: "トラブルシューティング"
network: "ネットワーク接続"
checking-network: "ネットワーク接続を確認中"
internet: "インターネット接続"
checking-internet: "インターネット接続を確認中"
server: "サーバー接続"
checking-server: "サーバー接続を確認中"
finding: "問題を調べています"
no-network: "ネットワークに接続されていません"
no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。"
no-internet: "インターネットに接続されていません"
no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。"
no-server: "Misskeyのサーバーに接続できません"
no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。"
success: "Misskeyのサーバーに接続できました"
success-desc: "正常に接続できるようです。ページを再度読み込みしてください。"
mk-forkit:
open-github-link: "View source on Github"
@ -76,6 +106,13 @@ common:
show-result: "結果を見る"
voted: "投票済み"
mk-post-menu:
pin: "ピン留め"
pinned: "ピン留めしました"
select: "カテゴリを選択"
categorize: "決定"
categorized: "カテゴリを報告しました。これによりMisskeyが賢くなり、投稿の自動カテゴライズに役立てられます。ご協力ありがとうございました。"
mk-reaction-picker:
choose-reaction: "リアクションを選択"
@ -127,8 +164,24 @@ common:
mk-uploader:
waiting: "待機中"
ch:
tags:
mk-index:
new: "チャンネルを作成"
channel-title: "チャンネルのタイトル"
mk-channel-form:
textarea: "書いて"
upload: "アップロード"
drive: "ドライブ"
post: "やる"
posting: "やってます"
desktop:
tags:
mk-api-info:
regenerate-token: "パスワードを入力してください"
mk-drive-browser-base-contextmenu:
create-folder: "フォルダーを作成"
upload: "ファイルをアップロード"
@ -189,9 +242,19 @@ desktop:
mk-drive-browser-nav-folder:
drive: "ドライブ"
mk-nav-home-widget:
about: "Misskeyについて"
stats: "統計"
status: "ステータス"
wiki: "Wiki"
donors: "ドナー"
repository: "リポジトリ"
develop: "開発者"
mk-ui-header-nav:
home: "ホーム"
messaging: "メッセージ"
ch: "チャンネル"
info: "お知らせ"
mk-ui-header-search:
@ -204,6 +267,14 @@ desktop:
settings: "設定"
signout: "サインアウト"
mk-password-setting:
reset: "パスワードを変更する"
enter-current-password: "現在のパスワードを入力してください"
enter-new-password: "新しいパスワードを入力してください"
enter-new-password-again: "もう一度新しいパスワードを入力してください"
not-match: "新しいパスワードが一致しません"
changed: "パスワードを変更しました"
mk-post-form:
post-placeholder: "いまどうしてる?"
reply-placeholder: "この投稿への返信..."
@ -231,6 +302,13 @@ desktop:
attaches: "添付: {}メディア"
uploading-media: "{}個のメディアをアップロード中"
mk-post-page:
prev: "前の投稿"
next: "次の投稿"
mk-settings:
password: "パスワード"
mk-timeline-post:
reposted-by: "{}がRepost"
reply: "返信"
@ -289,6 +367,9 @@ desktop:
mobile:
tags:
mk-selectdrive-page:
select-file: "ファイルを選択"
mk-drive-file-viewer:
download: "ダウンロード"
rename: "名前を変更"
@ -325,19 +406,46 @@ mobile:
mk-notifications-page:
notifications: "通知"
read-all: "すべての通知を既読にしますか?"
mk-post-page:
submit: "投稿"
title: "投稿"
prev: "前の投稿"
next: "次の投稿"
mk-search-page:
search: "検索"
mk-settings:
signed-in-as: "{}としてサインイン中"
mk-settings-page:
profile: "プロフィール"
applications: "アプリケーション"
twitter-integration: "Twitter連携"
signin-history: "ログイン履歴"
api: "API"
link: "Misskeyリンク"
settings: "設定"
signout: "サインアウト"
mk-profile-setting-page:
title: "プロフィール設定"
mk-profile-setting:
will-be-published: "これらのプロフィールは公開されます。"
name: "名前"
location: "場所"
description: "自己紹介"
birthday: "誕生日"
avatar: "アバター"
banner: "バナー"
avatar-saved: "アバターを保存しました"
banner-saved: "バナーを保存しました"
set-avatar: "アバターを選択する"
set-banner: "バナーを選択する"
save: "保存"
saved: "プロフィールを保存しました"
mk-user-followers-page:
followers-of: "{}のフォロワー"
@ -400,6 +508,7 @@ mobile:
home: "ホーム"
notifications: "通知"
messaging: "メッセージ"
ch: "チャンネル"
search: "検索"
drive: "ドライブ"
settings: "設定"
@ -416,13 +525,46 @@ mobile:
no-posts-with-media: "メディア付き投稿はありません。"
mk-user:
is-followed: "フォローされています"
follows-you: "フォローされています"
following: "フォロー"
followers: "フォロワー"
posts: "タイムライン"
posts-count: "ポスト"
posts: "投稿"
overview: "概要"
timeline: "タイムライン"
media: "メディア"
mk-user-overview:
recent-posts: "最近の投稿"
images: "画像"
activity: "アクティビティ"
keywords: "キーワード"
domains: "頻出ドメイン"
frequently-replied-users: "よく会話するユーザー"
followers-you-know: "知り合いのフォロワー"
last-used-at: "最終ログイン"
mk-user-overview-posts:
loading: "読み込み中"
no-posts: "投稿はありません"
mk-user-overview-photos:
loading: "読み込み中"
no-photos: "写真はありません"
mk-user-overview-keywords:
no-keywords: "キーワードはありません(十分な数の投稿をしていない可能性があります)"
mk-user-overview-domains:
no-domains: "よく表れるドメインは検出されませんでした"
mk-user-overview-frequently-replied-users:
loading: "読み込み中"
no-users: "よく会話するユーザーはいません"
mk-user-overview-followers-you-know:
loading: "読み込み中"
no-users: "知り合いのユーザーはいません"
mk-users-list:
all: "すべて"
known: "知り合い"

View file

@ -1,7 +1,7 @@
{
"name": "misskey",
"author": "syuilo <i@syuilo.com>",
"version": "0.0.2380",
"version": "0.0.2807",
"license": "MIT",
"description": "A miniblog-based SNS",
"bugs": "https://github.com/syuilo/misskey/issues",
@ -21,19 +21,19 @@
"test": "gulp test"
},
"devDependencies": {
"@types/bcryptjs": "2.4.0",
"@types/bcryptjs": "2.4.1",
"@types/body-parser": "1.16.5",
"@types/chai": "4.0.4",
"@types/chai-http": "3.0.2",
"@types/chalk": "0.4.31",
"@types/chai-http": "3.0.3",
"@types/chalk": "2.2.0",
"@types/compression": "0.0.34",
"@types/cors": "2.8.1",
"@types/debug": "0.0.30",
"@types/deep-equal": "1.0.1",
"@types/elasticsearch": "5.0.14",
"@types/event-stream": "3.3.31",
"@types/express": "4.0.36",
"@types/gm": "1.17.32",
"@types/elasticsearch": "5.0.17",
"@types/event-stream": "3.3.32",
"@types/express": "4.0.37",
"@types/gm": "1.17.33",
"@types/gulp": "4.0.3",
"@types/gulp-htmlmin": "1.3.30",
"@types/gulp-mocha": "0.0.30",
@ -47,82 +47,88 @@
"@types/is-root": "1.0.0",
"@types/is-url": "1.2.28",
"@types/js-yaml": "3.9.0",
"@types/mocha": "2.2.41",
"@types/mongodb": "2.2.10",
"@types/monk": "1.0.5",
"@types/morgan": "1.7.32",
"@types/ms": "0.7.29",
"@types/multer": "1.3.2",
"@types/node": "8.0.24",
"@types/mocha": "2.2.44",
"@types/mongodb": "2.2.13",
"@types/monk": "1.0.6",
"@types/morgan": "1.7.35",
"@types/ms": "0.7.30",
"@types/multer": "1.3.5",
"@types/node": "8.0.47",
"@types/ratelimiter": "2.1.28",
"@types/redis": "2.6.0",
"@types/request": "2.0.1",
"@types/redis": "2.8.1",
"@types/request": "2.0.7",
"@types/rimraf": "2.0.2",
"@types/riot": "3.6.0",
"@types/serve-favicon": "2.2.28",
"@types/uuid": "3.4.0",
"@types/webpack": "3.0.9",
"@types/webpack-stream": "3.2.7",
"@types/riot": "3.6.1",
"@types/serve-favicon": "2.2.29",
"@types/uuid": "3.4.3",
"@types/webpack": "3.0.14",
"@types/uuid": "3.4.3",
"@types/webpack": "3.0.13",
"@types/webpack-stream": "3.2.8",
"@types/websocket": "0.0.34",
"chai": "4.1.1",
"awesome-typescript-loader": "3.3.0",
"chai": "4.1.2",
"chai-http": "3.0.0",
"css-loader": "0.28.5",
"css-loader": "0.28.7",
"event-stream": "3.3.4",
"gulp": "3.9.1",
"gulp-cssnano": "2.1.2",
"gulp-imagemin": "3.3.0",
"gulp-htmlmin": "3.0.0",
"gulp-imagemin": "3.4.0",
"gulp-mocha": "4.3.1",
"gulp-pug": "3.3.0",
"gulp-rename": "1.2.2",
"gulp-replace": "0.6.1",
"gulp-tslint": "8.1.2",
"gulp-typescript": "3.2.1",
"gulp-typescript": "3.2.2",
"gulp-uglify": "3.0.0",
"gulp-util": "3.0.8",
"mocha": "3.5.0",
"mocha": "3.5.3",
"riot-tag-loader": "1.0.0",
"string-replace-webpack-plugin": "0.1.3",
"style-loader": "0.18.2",
"style-loader": "0.19.0",
"stylus": "0.54.5",
"stylus-loader": "3.0.1",
"swagger-jsdoc": "1.9.7",
"tslint": "5.6.0",
"tslint": "5.7.0",
"uglify-es": "3.0.27",
"uglify-es-webpack-plugin": "0.10.0",
"uglify-js": "git+https://github.com/mishoo/UglifyJS2.git#harmony",
"webpack": "3.5.5"
"uglifyjs-webpack-plugin": "1.0.1",
"webpack": "3.8.1"
},
"dependencies": {
"@prezzemolo/rap": "0.1.2",
"accesses": "2.5.0",
"animejs": "2.0.2",
"animejs": "2.2.0",
"autwh": "0.0.1",
"bcryptjs": "2.4.3",
"body-parser": "1.17.2",
"cafy": "2.4.0",
"chalk": "2.1.0",
"compression": "1.7.0",
"body-parser": "1.18.2",
"cafy": "3.0.0",
"chalk": "2.3.0",
"compression": "1.7.1",
"cors": "2.8.4",
"cropperjs": "1.0.0-rc.3",
"cropperjs": "1.1.3",
"crypto": "1.0.1",
"debug": "3.0.0",
"debug": "3.1.0",
"deep-equal": "1.0.1",
"deepcopy": "0.6.3",
"diskusage": "^0.2.2",
"diskusage": "0.2.2",
"download": "6.2.5",
"elasticsearch": "13.3.1",
"escape-regexp": "0.0.1",
"express": "4.15.4",
"file-type": "6.1.0",
"file-type": "7.2.0",
"fuckadblock": "3.2.1",
"gm": "1.23.0",
"inquirer": "3.2.2",
"inquirer": "3.3.0",
"is-root": "1.0.0",
"is-url": "1.2.2",
"js-yaml": "3.9.1",
"mongodb": "2.2.31",
"monk": "6.0.3",
"morgan": "1.8.2",
"js-yaml": "3.10.0",
"mecab-async": "^0.1.0",
"moji": "^0.5.1",
"mongodb": "2.2.33",
"monk": "6.0.5",
"morgan": "1.9.0",
"ms": "2.0.0",
"multer": "1.3.0",
"nprogress": "0.2.0",
@ -130,26 +136,26 @@
"page": "1.7.1",
"pictograph": "2.0.4",
"prominence": "0.2.0",
"pug": "2.0.0-rc.3",
"pug": "2.0.0-rc.4",
"ratelimiter": "3.0.3",
"recaptcha-promise": "0.1.3",
"reconnecting-websocket": "3.2.0",
"reconnecting-websocket": "3.2.2",
"redis": "2.8.0",
"request": "2.81.0",
"rimraf": "2.6.1",
"riot": "3.6.2",
"request": "2.83.0",
"rimraf": "2.6.2",
"riot": "3.7.4",
"rndstr": "1.0.0",
"s-age": "1.1.0",
"serve-favicon": "2.4.3",
"serve-favicon": "2.4.5",
"summaly": "2.0.3",
"syuilo-password-strength": "0.0.1",
"tcp-port-used": "0.1.2",
"textarea-caret": "3.0.2",
"ts-node": "3.3.0",
"typescript": "2.4.2",
"typescript": "2.6.1",
"uuid": "3.1.0",
"vhost": "3.0.2",
"websocket": "1.0.24",
"websocket": "1.0.25",
"xev": "2.0.0"
}
}

View file

@ -1,6 +1,6 @@
import * as express from 'express';
import App from './models/app';
import User from './models/user';
import { default as User, IUser } from './models/user';
import AccessToken from './models/access-token';
import isNativeToken from './common/is-native-token';
@ -13,10 +13,10 @@ export interface IAuthContext {
/**
* Authenticated user
*/
user: any;
user: IUser;
/**
* Weather if the request is via the User-Native Token or not
* Whether requested with a User-Native Token
*/
isSecure: boolean;
}
@ -25,11 +25,15 @@ export default (req: express.Request) => new Promise<IAuthContext>(async (resolv
const token = req.body['i'] as string;
if (token == null) {
return resolve({ app: null, user: null, isSecure: false });
return resolve({
app: null,
user: null,
isSecure: false
});
}
if (isNativeToken(token)) {
const user = await User
const user: IUser = await User
.findOne({ token: token });
if (user === null) {
@ -56,6 +60,10 @@ export default (req: express.Request) => new Promise<IAuthContext>(async (resolv
const user = await User
.findOne({ _id: accessToken.user_id });
return resolve({ app: app, user: user, isSecure: false });
return resolve({
app: app,
user: user,
isSecure: false
});
}
});

398
src/api/bot/core.ts Normal file
View file

@ -0,0 +1,398 @@
import * as EventEmitter from 'events';
import * as bcrypt from 'bcryptjs';
import User, { IUser, init as initUser } from '../models/user';
import getPostSummary from '../../common/get-post-summary';
import getUserSummary from '../../common/get-user-summary';
import Othello, { ai as othelloAi } from '../../common/othello';
const hmm = [
'',
'ふぅ~む...',
'ちょっと何言ってるかわからないです',
'「ヘルプ」と言うと利用可能な操作が確認できますよ'
];
/**
* Botの頭脳
*/
export default class BotCore extends EventEmitter {
public user: IUser = null;
private context: Context = null;
constructor(user?: IUser) {
super();
this.user = user;
}
public clearContext() {
this.setContext(null);
}
public setContext(context: Context) {
this.context = context;
this.emit('updated');
if (context) {
context.on('updated', () => {
this.emit('updated');
});
}
}
public export() {
return {
user: this.user,
context: this.context ? this.context.export() : null
};
}
protected _import(data) {
this.user = data.user ? initUser(data.user) : null;
this.setContext(data.context ? Context.import(this, data.context) : null);
}
public static import(data) {
const bot = new BotCore();
bot._import(data);
return bot;
}
public async q(query: string): Promise<string | void> {
if (this.context != null) {
return await this.context.q(query);
}
if (/^@[a-zA-Z0-9-]+$/.test(query)) {
return await this.showUserCommand(query);
}
switch (query) {
case 'ping':
return 'PONG';
case 'help':
case 'ヘルプ':
return '利用可能なコマンド一覧です:\n' +
'help: これです\n' +
'me: アカウント情報を見ます\n' +
'login, signin: サインインします\n' +
'logout, signout: サインアウトします\n' +
'post: 投稿します\n' +
'tl: タイムラインを見ます\n' +
'@<ユーザー名>: ユーザーを表示します';
case 'me':
return this.user ? `${this.user.name}としてサインインしています。\n\n${getUserSummary(this.user)}` : 'サインインしていません';
case 'login':
case 'signin':
case 'ログイン':
case 'サインイン':
if (this.user != null) return '既にサインインしていますよ!';
this.setContext(new SigninContext(this));
return await this.context.greet();
case 'logout':
case 'signout':
case 'ログアウト':
case 'サインアウト':
if (this.user == null) return '今はサインインしてないですよ!';
this.signout();
return 'ご利用ありがとうございました <3';
case 'post':
case '投稿':
if (this.user == null) return 'まずサインインしてください。';
this.setContext(new PostContext(this));
return await this.context.greet();
case 'tl':
case 'タイムライン':
return await this.tlCommand();
case 'guessing-game':
case '数当てゲーム':
this.setContext(new GuessingGameContext(this));
return await this.context.greet();
case 'othello':
case 'オセロ':
this.setContext(new OthelloContext(this));
return await this.context.greet();
default:
return hmm[Math.floor(Math.random() * hmm.length)];
}
}
public signin(user: IUser) {
this.user = user;
this.emit('signin', user);
this.emit('updated');
}
public signout() {
const user = this.user;
this.user = null;
this.emit('signout', user);
this.emit('updated');
}
public async refreshUser() {
this.user = await User.findOne({
_id: this.user._id
}, {
fields: {
data: false
}
});
this.emit('updated');
}
public async tlCommand(): Promise<string | void> {
if (this.user == null) return 'まずサインインしてください。';
const tl = await require('../endpoints/posts/timeline')({
limit: 5
}, this.user);
const text = tl
.map(post => getPostSummary(post))
.join('\n-----\n');
return text;
}
public async showUserCommand(q: string): Promise<string | void> {
try {
const user = await require('../endpoints/users/show')({
username: q.substr(1)
}, this.user);
const text = getUserSummary(user);
return text;
} catch (e) {
return `問題が発生したようです...: ${e}`;
}
}
}
abstract class Context extends EventEmitter {
protected bot: BotCore;
public abstract async greet(): Promise<string>;
public abstract async q(query: string): Promise<string>;
public abstract export(): any;
constructor(bot: BotCore) {
super();
this.bot = bot;
}
public static import(bot: BotCore, data: any) {
if (data.type == 'guessing-game') return GuessingGameContext.import(bot, data.content);
if (data.type == 'othello') return OthelloContext.import(bot, data.content);
if (data.type == 'post') return PostContext.import(bot, data.content);
if (data.type == 'signin') return SigninContext.import(bot, data.content);
return null;
}
}
class SigninContext extends Context {
private temporaryUser: IUser = null;
public async greet(): Promise<string> {
return 'まずユーザー名を教えてください:';
}
public async q(query: string): Promise<string> {
if (this.temporaryUser == null) {
// Fetch user
const user: IUser = await User.findOne({
username_lower: query.toLowerCase()
}, {
fields: {
data: false
}
});
if (user === null) {
return `${query}というユーザーは存在しませんでした... もう一度教えてください:`;
} else {
this.temporaryUser = user;
this.emit('updated');
return `パスワードを教えてください:`;
}
} else {
// Compare password
const same = bcrypt.compareSync(query, this.temporaryUser.password);
if (same) {
this.bot.signin(this.temporaryUser);
this.bot.clearContext();
return `${this.temporaryUser.name}さん、おかえりなさい!`;
} else {
return `パスワードが違います... もう一度教えてください:`;
}
}
}
public export() {
return {
type: 'signin',
content: {
temporaryUser: this.temporaryUser
}
};
}
public static import(bot: BotCore, data: any) {
const context = new SigninContext(bot);
context.temporaryUser = data.temporaryUser;
return context;
}
}
class PostContext extends Context {
public async greet(): Promise<string> {
return '内容:';
}
public async q(query: string): Promise<string> {
await require('../endpoints/posts/create')({
text: query
}, this.bot.user);
this.bot.clearContext();
return '投稿しましたよ!';
}
public export() {
return {
type: 'post'
};
}
public static import(bot: BotCore, data: any) {
const context = new PostContext(bot);
return context;
}
}
class GuessingGameContext extends Context {
private secret: number;
private history: number[] = [];
public async greet(): Promise<string> {
this.secret = Math.floor(Math.random() * 100);
this.emit('updated');
return '0~100の秘密の数を当ててみてください:';
}
public async q(query: string): Promise<string> {
if (query == 'やめる') {
this.bot.clearContext();
return 'やめました。';
}
const guess = parseInt(query, 10);
if (isNaN(guess)) {
return '整数で推測してください。「やめる」と言うとゲームをやめます。';
}
const firsttime = this.history.indexOf(guess) === -1;
this.history.push(guess);
this.emit('updated');
if (this.secret < guess) {
return firsttime ? `${guess}よりも小さいですね` : `もう一度言いますが${guess}より小さいですよ`;
} else if (this.secret > guess) {
return firsttime ? `${guess}よりも大きいですね` : `もう一度言いますが${guess}より大きいですよ`;
} else {
this.bot.clearContext();
return `正解です🎉 (${this.history.length}回目で当てました)`;
}
}
public export() {
return {
type: 'guessing-game',
content: {
secret: this.secret,
history: this.history
}
};
}
public static import(bot: BotCore, data: any) {
const context = new GuessingGameContext(bot);
context.secret = data.secret;
context.history = data.history;
return context;
}
}
class OthelloContext extends Context {
private othello: Othello = null;
constructor(bot: BotCore) {
super(bot);
this.othello = new Othello();
}
public async greet(): Promise<string> {
return this.othello.toPatternString('black');
}
public async q(query: string): Promise<string> {
if (query == 'やめる') {
this.bot.clearContext();
return 'オセロをやめました。';
}
const n = parseInt(query, 10);
if (isNaN(n)) {
return '番号で指定してください。「やめる」と言うとゲームをやめます。';
}
this.othello.setByNumber('black', n);
const s = this.othello.toString() + '\n\n...(AI)...\n\n';
othelloAi('white', this.othello);
if (this.othello.getPattern('black').length === 0) {
this.bot.clearContext();
const blackCount = this.othello.board.map(row => row.filter(s => s == 'black').length).reduce((a, b) => a + b);
const whiteCount = this.othello.board.map(row => row.filter(s => s == 'white').length).reduce((a, b) => a + b);
const winner = blackCount == whiteCount ? '引き分け' : blackCount > whiteCount ? '黒の勝ち' : '白の勝ち';
return this.othello.toString() + `\n\n終了\n\n黒${blackCount}、白${whiteCount}${winner}です。`;
} else {
this.emit('updated');
return s + this.othello.toPatternString('black');
}
}
public export() {
return {
type: 'othello',
content: {
board: this.othello.board
}
};
}
public static import(bot: BotCore, data: any) {
const context = new OthelloContext(bot);
context.othello = new Othello();
context.othello.board = data.board;
return context;
}
}

View file

@ -0,0 +1,234 @@
import * as EventEmitter from 'events';
import * as express from 'express';
import * as request from 'request';
import * as crypto from 'crypto';
import User from '../../models/user';
import config from '../../../conf';
import BotCore from '../core';
import _redis from '../../../db/redis';
import prominence = require('prominence');
import getPostSummary from '../../../common/get-post-summary';
const redis = prominence(_redis);
// SEE: https://developers.line.me/media/messaging-api/messages/sticker_list.pdf
const stickers = [
'297',
'298',
'299',
'300',
'301',
'302',
'303',
'304',
'305',
'306',
'307'
];
class LineBot extends BotCore {
private replyToken: string;
private reply(messages: any[]) {
request.post({
url: 'https://api.line.me/v2/bot/message/reply',
headers: {
'Authorization': `Bearer ${config.line_bot.channel_access_token}`
},
json: {
replyToken: this.replyToken,
messages: messages
}
}, (err, res, body) => {
if (err) {
console.error(err);
return;
}
});
}
public async react(ev: any): Promise<void> {
this.replyToken = ev.replyToken;
switch (ev.type) {
// メッセージ
case 'message':
switch (ev.message.type) {
// テキスト
case 'text':
const res = await this.q(ev.message.text);
if (res == null) return;
// 返信
this.reply([{
type: 'text',
text: res
}]);
break;
// スタンプ
case 'sticker':
// スタンプで返信
this.reply([{
type: 'sticker',
packageId: '4',
stickerId: stickers[Math.floor(Math.random() * stickers.length)]
}]);
break;
}
break;
// postback
case 'postback':
const data = ev.postback.data;
const cmd = data.split('|')[0];
const arg = data.split('|')[1];
switch (cmd) {
case 'showtl':
this.showUserTimelinePostback(arg);
break;
}
break;
}
}
public static import(data) {
const bot = new LineBot();
bot._import(data);
return bot;
}
public async showUserCommand(q: string) {
const user = await require('../../endpoints/users/show')({
username: q.substr(1)
}, this.user);
const actions = [];
actions.push({
type: 'postback',
label: 'タイムラインを見る',
data: `showtl|${user.id}`
});
if (user.twitter) {
actions.push({
type: 'uri',
label: 'Twitterアカウントを見る',
uri: `https://twitter.com/${user.twitter.screen_name}`
});
}
actions.push({
type: 'uri',
label: 'Webで見る',
uri: `${config.url}/${user.username}`
});
this.reply([{
type: 'template',
altText: await super.showUserCommand(q),
template: {
type: 'buttons',
thumbnailImageUrl: `${user.avatar_url}?thumbnail&size=1024`,
title: `${user.name} (@${user.username})`,
text: user.description || '(no description)',
actions: actions
}
}]);
}
public async showUserTimelinePostback(userId: string) {
const tl = await require('../../endpoints/users/posts')({
user_id: userId,
limit: 5
}, this.user);
const text = `${tl[0].user.name}さんのタイムラインはこちらです:\n\n` + tl
.map(post => getPostSummary(post))
.join('\n-----\n');
this.reply([{
type: 'text',
text: text
}]);
}
}
module.exports = async (app: express.Application) => {
if (config.line_bot == null) return;
const handler = new EventEmitter();
handler.on('event', async (ev) => {
const sourceId = ev.source.userId;
const sessionId = `line-bot-sessions:${sourceId}`;
const session = await redis.get(sessionId);
let bot: LineBot;
if (session == null) {
const user = await User.findOne({
line: {
user_id: sourceId
}
});
bot = new LineBot(user);
bot.on('signin', user => {
User.update(user._id, {
$set: {
line: {
user_id: sourceId
}
}
});
});
bot.on('signout', user => {
User.update(user._id, {
$set: {
line: {
user_id: null
}
}
});
});
redis.set(sessionId, JSON.stringify(bot.export()));
} else {
bot = LineBot.import(JSON.parse(session));
}
bot.on('updated', () => {
redis.set(sessionId, JSON.stringify(bot.export()));
});
if (session != null) bot.refreshUser();
bot.react(ev);
});
app.post('/hooks/line', (req, res, next) => {
// req.headers['x-line-signature'] は常に string ですが、型定義の都合上
// string | string[] になっているので string を明示しています
const sig1 = req.headers['x-line-signature'] as string;
const hash = crypto.createHmac('SHA256', config.line_bot.channel_secret)
.update((req as any).rawBody);
const sig2 = hash.digest('base64');
// シグネチャ比較
if (sig1 === sig2) {
req.body.events.forEach(ev => {
handler.emit('event', ev);
});
res.sendStatus(200);
} else {
res.sendStatus(400);
}
});
};

View file

@ -4,14 +4,27 @@ import * as gm from 'gm';
import * as debug from 'debug';
import fileType = require('file-type');
import prominence = require('prominence');
import DriveFile from '../models/drive-file';
import DriveFile, { getGridFSBucket } from '../models/drive-file';
import DriveFolder from '../models/drive-folder';
import serialize from '../serializers/drive-file';
import event from '../event';
import config from '../../conf';
import { Duplex } from 'stream';
const log = debug('misskey:register-drive-file');
const addToGridFS = (name, binary, metadata): Promise<any> => new Promise(async (resolve, reject) => {
const dataStream = new Duplex();
dataStream.push(binary);
dataStream.push(null);
const bucket = await getGridFSBucket();
const writeStream = bucket.openUploadStream(name, { metadata });
writeStream.once('finish', (doc) => { resolve(doc); });
writeStream.on('error', reject);
dataStream.pipe(writeStream);
});
/**
* Add file to drive
*
@ -58,7 +71,7 @@ export default (
// Generate hash
const hash = crypto
.createHash('sha256')
.createHash('md5')
.update(data)
.digest('hex') as string;
@ -67,8 +80,8 @@ export default (
if (!force) {
// Check if there is a file with the same hash
const much = await DriveFile.findOne({
user_id: user._id,
hash: hash
md5: hash,
'metadata.user_id': user._id
});
if (much !== null) {
@ -82,13 +95,13 @@ export default (
// Calculate drive usage
const usage = ((await DriveFile
.aggregate([
{ $match: { user_id: user._id } },
{ $match: { 'metadata.user_id': user._id } },
{ $project: {
datasize: true
length: true
}},
{ $group: {
_id: null,
usage: { $sum: '$datasize' }
usage: { $sum: '$length' }
}}
]))[0] || {
usage: 0
@ -131,21 +144,15 @@ export default (
}
// Create DriveFile document
const file = await DriveFile.insert({
created_at: new Date(),
const file = await addToGridFS(`${user._id}/${name}`, data, {
user_id: user._id,
folder_id: folder !== null ? folder._id : null,
data: data,
datasize: size,
type: mime,
name: name,
comment: comment,
hash: hash,
properties: properties
});
delete file.data;
log(`drive file has been created ${file._id}`);
resolve(file);

View file

@ -0,0 +1,3 @@
import rndstr from 'rndstr';
export default () => `!${rndstr('a-zA-Z0-9', 32)}`;

View file

@ -0,0 +1,52 @@
import * as mongo from 'mongodb';
import { default as Notification, INotification } from '../models/notification';
import publishUserStream from '../event';
/**
* Mark as read notification(s)
*/
export default (
user: string | mongo.ObjectID,
message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[]
) => new Promise<any>(async (resolve, reject) => {
const userId = mongo.ObjectID.prototype.isPrototypeOf(user)
? user
: new mongo.ObjectID(user);
const ids: mongo.ObjectID[] = Array.isArray(message)
? mongo.ObjectID.prototype.isPrototypeOf(message[0])
? (message as mongo.ObjectID[])
: typeof message[0] === 'string'
? (message as string[]).map(m => new mongo.ObjectID(m))
: (message as INotification[]).map(m => m._id)
: mongo.ObjectID.prototype.isPrototypeOf(message)
? [(message as mongo.ObjectID)]
: typeof message === 'string'
? [new mongo.ObjectID(message)]
: [(message as INotification)._id];
// Update documents
await Notification.update({
_id: { $in: ids },
is_read: false
}, {
$set: {
is_read: true
}
}, {
multi: true
});
// Calc count of my unread notifications
const count = await Notification
.count({
notifiee_id: userId,
is_read: false
});
if (count == 0) {
// 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行
publishUserStream(userId, 'read_all_notifications');
}
});

View file

@ -159,6 +159,18 @@ const endpoints: Endpoint[] = [
},
kind: 'account-write'
},
{
name: 'i/change_password',
withCredential: true
},
{
name: 'i/regenerate_token',
withCredential: true
},
{
name: 'i/pin',
kind: 'account-write'
},
{
name: 'i/appdata/get',
withCredential: true
@ -183,6 +195,11 @@ const endpoints: Endpoint[] = [
withCredential: true,
kind: 'notification-read'
},
{
name: 'notifications/get_unread_count',
withCredential: true,
kind: 'notification-read'
},
{
name: 'notifications/delete',
withCredential: true,
@ -193,11 +210,6 @@ const endpoints: Endpoint[] = [
withCredential: true,
kind: 'notification-write'
},
{
name: 'notifications/mark_as_read',
withCredential: true,
kind: 'notification-write'
},
{
name: 'notifications/mark_as_read_all',
withCredential: true,
@ -314,6 +326,9 @@ const endpoints: Endpoint[] = [
withCredential: true,
kind: 'account-read'
},
{
name: 'users/get_frequently_replied_users'
},
{
name: 'following/create',
@ -382,6 +397,10 @@ const endpoints: Endpoint[] = [
name: 'posts/trend',
withCredential: true
},
{
name: 'posts/categorize',
withCredential: true
},
{
name: 'posts/reactions',
withCredential: true
@ -455,8 +474,33 @@ const endpoints: Endpoint[] = [
name: 'messaging/messages/create',
withCredential: true,
kind: 'messaging-write'
},
{
name: 'channels/create',
withCredential: true,
limit: {
duration: ms('1hour'),
max: 3,
minInterval: ms('10seconds')
}
},
{
name: 'channels/show'
},
{
name: 'channels/posts'
},
{
name: 'channels/watch',
withCredential: true
},
{
name: 'channels/unwatch',
withCredential: true
},
{
name: 'channels'
},
];
export default endpoints;

View file

@ -19,7 +19,7 @@ module.exports = params => new Promise(async (res, rej) => {
.aggregate([
{ $project: {
repost_id: '$repost_id',
reply_to_id: '$reply_to_id',
reply_id: '$reply_id',
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},
{ $project: {
@ -34,7 +34,7 @@ module.exports = params => new Promise(async (res, rej) => {
then: 'repost',
else: {
$cond: {
if: { $ne: ['$reply_to_id', null] },
if: { $ne: ['$reply_id', null] },
then: 'reply',
else: 'post'
}

View file

@ -26,7 +26,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
const datas = await Post
.aggregate([
{ $match: { reply_to: post._id } },
{ $match: { reply: post._id } },
{ $project: {
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},

View file

@ -40,7 +40,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
{ $match: { user_id: user._id } },
{ $project: {
repost_id: '$repost_id',
reply_to_id: '$reply_to_id',
reply_id: '$reply_id',
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},
{ $project: {
@ -55,7 +55,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
then: 'repost',
else: {
$cond: {
if: { $ne: ['$reply_to_id', null] },
if: { $ne: ['$reply_id', null] },
then: 'reply',
else: 'post'
}

View file

@ -34,7 +34,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
{ $match: { user_id: user._id } },
{ $project: {
repost_id: '$repost_id',
reply_to_id: '$reply_to_id',
reply_id: '$reply_id',
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},
{ $project: {
@ -49,7 +49,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
then: 'repost',
else: {
$cond: {
if: { $ne: ['$reply_to_id', null] },
if: { $ne: ['$reply_id', null] },
then: 'reply',
else: 'post'
}

View file

@ -0,0 +1,59 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Channel from '../models/channel';
import serialize from '../serializers/channel';
/**
* Get all channels
*
* @param {any} params
* @param {any} me
* @return {Promise<any>}
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'limit' parameter
const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
if (limitErr) return rej('invalid limit param');
// Get 'since_id' parameter
const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
if (sinceIdErr) return rej('invalid since_id param');
// Get 'max_id' parameter
const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
if (maxIdErr) return rej('invalid max_id param');
// Check if both of since_id and max_id is specified
if (sinceId && maxId) {
return rej('cannot set since_id and max_id');
}
// Construct query
const sort = {
_id: -1
};
const query = {} as any;
if (sinceId) {
sort._id = 1;
query._id = {
$gt: sinceId
};
} else if (maxId) {
query._id = {
$lt: maxId
};
}
// Issue query
const channels = await Channel
.find(query, {
limit: limit,
sort: sort
});
// Serialize
res(await Promise.all(channels.map(async channel =>
await serialize(channel, me))));
});

View file

@ -0,0 +1,39 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Channel from '../../models/channel';
import Watching from '../../models/channel-watching';
import serialize from '../../serializers/channel';
/**
* Create a channel
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'title' parameter
const [title, titleErr] = $(params.title).string().range(1, 100).$;
if (titleErr) return rej('invalid title param');
// Create a channel
const channel = await Channel.insert({
created_at: new Date(),
user_id: user._id,
title: title,
index: 0,
watching_count: 1
});
// Response
res(await serialize(channel));
// Create Watching
await Watching.insert({
created_at: new Date(),
user_id: user._id,
channel_id: channel._id
});
});

View file

@ -0,0 +1,79 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import { default as Channel, IChannel } from '../../models/channel';
import { default as Post, IPost } from '../../models/post';
import serialize from '../../serializers/post';
/**
* Show a posts of a channel
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'limit' parameter
const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$;
if (limitErr) return rej('invalid limit param');
// Get 'since_id' parameter
const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
if (sinceIdErr) return rej('invalid since_id param');
// Get 'max_id' parameter
const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
if (maxIdErr) return rej('invalid max_id param');
// Check if both of since_id and max_id is specified
if (sinceId && maxId) {
return rej('cannot set since_id and max_id');
}
// Get 'channel_id' parameter
const [channelId, channelIdErr] = $(params.channel_id).id().$;
if (channelIdErr) return rej('invalid channel_id param');
// Fetch channel
const channel: IChannel = await Channel.findOne({
_id: channelId
});
if (channel === null) {
return rej('channel not found');
}
//#region Construct query
const sort = {
_id: -1
};
const query = {
channel_id: channel._id
} as any;
if (sinceId) {
sort._id = 1;
query._id = {
$gt: sinceId
};
} else if (maxId) {
query._id = {
$lt: maxId
};
}
//#endregion Construct query
// Issue query
const posts = await Post
.find(query, {
limit: limit,
sort: sort
});
// Serialize
res(await Promise.all(posts.map(async (post) =>
await serialize(post, user)
)));
});

View file

@ -0,0 +1,31 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import { default as Channel, IChannel } from '../../models/channel';
import serialize from '../../serializers/channel';
/**
* Show a channel
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'channel_id' parameter
const [channelId, channelIdErr] = $(params.channel_id).id().$;
if (channelIdErr) return rej('invalid channel_id param');
// Fetch channel
const channel: IChannel = await Channel.findOne({
_id: channelId
});
if (channel === null) {
return rej('channel not found');
}
// Serialize
res(await serialize(channel, user));
});

View file

@ -0,0 +1,60 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Channel from '../../models/channel';
import Watching from '../../models/channel-watching';
/**
* Unwatch a channel
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'channel_id' parameter
const [channelId, channelIdErr] = $(params.channel_id).id().$;
if (channelIdErr) return rej('invalid channel_id param');
//#region Fetch channel
const channel = await Channel.findOne({
_id: channelId
});
if (channel === null) {
return rej('channel not found');
}
//#endregion
//#region Check whether not watching
const exist = await Watching.findOne({
user_id: user._id,
channel_id: channel._id,
deleted_at: { $exists: false }
});
if (exist === null) {
return rej('already not watching');
}
//#endregion
// Delete watching
await Watching.update({
_id: exist._id
}, {
$set: {
deleted_at: new Date()
}
});
// Send response
res();
// Decrement watching count
Channel.update(channel._id, {
$inc: {
watching_count: -1
}
});
});

View file

@ -0,0 +1,58 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Channel from '../../models/channel';
import Watching from '../../models/channel-watching';
/**
* Watch a channel
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'channel_id' parameter
const [channelId, channelIdErr] = $(params.channel_id).id().$;
if (channelIdErr) return rej('invalid channel_id param');
//#region Fetch channel
const channel = await Channel.findOne({
_id: channelId
});
if (channel === null) {
return rej('channel not found');
}
//#endregion
//#region Check whether already watching
const exist = await Watching.findOne({
user_id: user._id,
channel_id: channel._id,
deleted_at: { $exists: false }
});
if (exist !== null) {
return rej('already watching');
}
//#endregion
// Create Watching
await Watching.insert({
created_at: new Date(),
user_id: user._id,
channel_id: channel._id
});
// Send response
res();
// Increment watching count
Channel.update(channel._id, {
$inc: {
watching_count: 1
}
});
});

View file

@ -14,16 +14,16 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Calculate drive usage
const usage = ((await DriveFile
.aggregate([
{ $match: { user_id: user._id } },
{ $match: { 'metadata.user_id': user._id } },
{
$project: {
datasize: true
length: true
}
},
{
$group: {
_id: null,
usage: { $sum: '$datasize' }
usage: { $sum: '$length' }
}
}
]))[0] || {

View file

@ -13,35 +13,35 @@ import serialize from '../../serializers/drive-file';
* @param {any} app
* @return {Promise<any>}
*/
module.exports = (params, user, app) => new Promise(async (res, rej) => {
module.exports = async (params, user, app) => {
// Get 'limit' parameter
const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
if (limitErr) return rej('invalid limit param');
if (limitErr) throw 'invalid limit param';
// Get 'since_id' parameter
const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
if (sinceIdErr) return rej('invalid since_id param');
if (sinceIdErr) throw 'invalid since_id param';
// Get 'max_id' parameter
const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
if (maxIdErr) return rej('invalid max_id param');
if (maxIdErr) throw 'invalid max_id param';
// Check if both of since_id and max_id is specified
if (sinceId && maxId) {
return rej('cannot set since_id and max_id');
throw 'cannot set since_id and max_id';
}
// Get 'folder_id' parameter
const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$;
if (folderIdErr) return rej('invalid folder_id param');
if (folderIdErr) throw 'invalid folder_id param';
// Construct query
const sort = {
_id: -1
};
const query = {
user_id: user._id,
folder_id: folderId
'metadata.user_id': user._id,
'metadata.folder_id': folderId
} as any;
if (sinceId) {
sort._id = 1;
@ -57,14 +57,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// Issue query
const files = await DriveFile
.find(query, {
fields: {
data: false
},
limit: limit,
sort: sort
});
// Serialize
res(await Promise.all(files.map(async file =>
await serialize(file))));
});
const _files = await Promise.all(files.map(file => serialize(file)));
return _files;
};

View file

@ -24,13 +24,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Issue query
const files = await DriveFile
.find({
name: name,
user_id: user._id,
folder_id: folderId
}, {
fields: {
data: false
}
'metadata.name': name,
'metadata.user_id': user._id,
'metadata.folder_id': folderId
});
// Serialize

View file

@ -12,28 +12,26 @@ import serialize from '../../../serializers/drive-file';
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
module.exports = async (params, user) => {
// Get 'file_id' parameter
const [fileId, fileIdErr] = $(params.file_id).id().$;
if (fileIdErr) return rej('invalid file_id param');
if (fileIdErr) throw 'invalid file_id param';
// Fetch file
const file = await DriveFile
.findOne({
_id: fileId,
user_id: user._id
}, {
fields: {
data: false
}
'metadata.user_id': user._id
});
if (file === null) {
return rej('file-not-found');
throw 'file-not-found';
}
// Serialize
res(await serialize(file, {
const _file = await serialize(file, {
detail: true
}));
});
});
return _file;
};

View file

@ -24,11 +24,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
const file = await DriveFile
.findOne({
_id: fileId,
user_id: user._id
}, {
fields: {
data: false
}
'metadata.user_id': user._id
});
if (file === null) {
@ -38,7 +34,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'name' parameter
const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$;
if (nameErr) return rej('invalid name param');
if (name) file.name = name;
if (name) file.metadata.name = name;
// Get 'folder_id' parameter
const [folderId, folderIdErr] = $(params.folder_id).optional.nullable.id().$;
@ -46,7 +42,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
if (folderId !== undefined) {
if (folderId === null) {
file.folder_id = null;
file.metadata.folder_id = null;
} else {
// Fetch folder
const folder = await DriveFolder
@ -59,14 +55,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
return rej('folder-not-found');
}
file.folder_id = folder._id;
file.metadata.folder_id = folder._id;
}
}
DriveFile.update(file._id, {
await DriveFile.update(file._id, {
$set: {
name: file.name,
folder_id: file.folder_id
'metadata.name': file.metadata.name,
'metadata.folder_id': file.metadata.folder_id
}
});

View file

@ -30,6 +30,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
});
// Serialize
res(await Promise.all(folders.map(async folder =>
await serialize(folder))));
res(await Promise.all(folders.map(folder => serialize(folder))));
});

View file

@ -4,7 +4,7 @@
import $ from 'cafy';
import DriveFolder from '../../../models/drive-folder';
import { isValidFolderName } from '../../../models/drive-folder';
import serialize from '../../../serializers/drive-file';
import serialize from '../../../serializers/drive-folder';
import event from '../../../event';
/**

View file

@ -21,7 +21,7 @@ module.exports = (params, user, app, isSecure) => new Promise(async (res, rej) =
const [data, dataError] = $(params.data).optional.object()
.pipe(obj => {
const hasInvalidData = Object.entries(obj).some(([k, v]) =>
$(k).string().match(/^[a-z_]+$/).isNg() && $(v).string().isNg());
$(k).string().match(/^[a-z_]+$/).nok() && $(v).string().nok());
return !hasInvalidData;
}).$;
if (dataError) return rej('invalid data param');

View file

@ -0,0 +1,42 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import * as bcrypt from 'bcryptjs';
import User from '../../models/user';
/**
* Change password
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'current_password' parameter
const [currentPassword, currentPasswordErr] = $(params.current_password).string().$;
if (currentPasswordErr) return rej('invalid current_password param');
// Get 'new_password' parameter
const [newPassword, newPasswordErr] = $(params.new_password).string().$;
if (newPasswordErr) return rej('invalid new_password param');
// Compare password
const same = bcrypt.compareSync(currentPassword, user.password);
if (!same) {
return rej('incorrect password');
}
// Generate hash of password
const salt = bcrypt.genSaltSync(8);
const hash = bcrypt.hashSync(newPassword, salt);
await User.update(user._id, {
$set: {
password: hash
}
});
res();
});

View file

@ -5,6 +5,7 @@ import $ from 'cafy';
import Notification from '../../models/notification';
import serialize from '../../serializers/notification';
import getFriends from '../../common/get-friends';
import read from '../../common/read-notification';
/**
* Get notifications
@ -91,17 +92,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Mark as read all
if (notifications.length > 0 && markAsRead) {
const ids = notifications
.filter(x => x.is_read == false)
.map(x => x._id);
// Update documents
await Notification.update({
_id: { $in: ids }
}, {
$set: { is_read: true }
}, {
multi: true
});
read(user._id, notifications);
}
});

View file

@ -0,0 +1,44 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import User from '../../models/user';
import Post from '../../models/post';
import serialize from '../../serializers/user';
/**
* Pin post
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'post_id' parameter
const [postId, postIdErr] = $(params.post_id).id().$;
if (postIdErr) return rej('invalid post_id param');
// Fetch pinee
const post = await Post.findOne({
_id: postId,
user_id: user._id
});
if (post === null) {
return rej('post not found');
}
await User.update(user._id, {
$set: {
pinned_post_id: post._id
}
});
// Serialize
const iObj = await serialize(user, user, {
detail: true
});
// Send response
res(iObj);
});

View file

@ -0,0 +1,42 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import * as bcrypt from 'bcryptjs';
import User from '../../models/user';
import event from '../../event';
import generateUserToken from '../../common/generate-native-user-token';
/**
* Regenerate native token
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = async (params, user) => new Promise(async (res, rej) => {
// Get 'password' parameter
const [password, passwordErr] = $(params.password).string().$;
if (passwordErr) return rej('invalid password param');
// Compare password
const same = bcrypt.compareSync(password, user.password);
if (!same) {
return rej('incorrect password');
}
// Generate secret
const secret = generateUserToken();
await User.update(user._id, {
$set: {
token: secret
}
});
res();
// Publish event
event(user._id, 'my_token_regenerated');
});

View file

@ -54,9 +54,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
if (fileId !== undefined) {
file = await DriveFile.findOne({
_id: fileId,
user_id: user._id
}, {
data: false
'metadata.user_id': user._id
});
if (file === null) {

View file

@ -0,0 +1,23 @@
/**
* Module dependencies
*/
import Notification from '../../models/notification';
/**
* Get count of unread notifications
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const count = await Notification
.count({
notifiee_id: user._id,
is_read: false
});
res({
count: count
});
});

View file

@ -1,47 +0,0 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Notification from '../../models/notification';
import serialize from '../../serializers/notification';
import event from '../../event';
/**
* Mark as read a notification
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const [notificationId, notificationIdErr] = $(params.notification_id).id().$;
if (notificationIdErr) return rej('invalid notification_id param');
// Get notification
const notification = await Notification
.findOne({
_id: notificationId,
i: user._id
});
if (notification === null) {
return rej('notification-not-found');
}
// Update
notification.is_read = true;
Notification.update({ _id: notification._id }, {
$set: {
is_read: true
}
});
// Response
res();
// Serialize
const notificationObj = await serialize(notification);
// Publish read_notification event
event(user._id, 'read_notification', notificationObj);
});

View file

@ -0,0 +1,32 @@
/**
* Module dependencies
*/
import Notification from '../../models/notification';
import event from '../../event';
/**
* Mark as read all notifications
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
// Update documents
await Notification.update({
notifiee_id: user._id,
is_read: false
}, {
$set: {
is_read: true
}
}, {
multi: true
});
// Response
res();
// 全ての通知を読みましたよというイベントを発行
event(user._id, 'read_all_notifications');
});

View file

@ -62,7 +62,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
}
if (reply != undefined) {
query.reply_to_id = reply ? { $exists: true, $ne: null } : null;
query.reply_id = reply ? { $exists: true, $ne: null } : null;
}
if (repost != undefined) {

View file

@ -0,0 +1,52 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Post from '../../models/post';
/**
* Categorize a post
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
if (!user.is_pro) {
return rej('This endpoint is available only from a Pro account');
}
// Get 'post_id' parameter
const [postId, postIdErr] = $(params.post_id).id().$;
if (postIdErr) return rej('invalid post_id param');
// Get categorizee
const post = await Post.findOne({
_id: postId
});
if (post === null) {
return rej('post not found');
}
if (post.is_category_verified) {
return rej('This post already has the verified category');
}
// Get 'category' parameter
const [category, categoryErr] = $(params.category).string().or([
'music', 'game', 'anime', 'it', 'gadgets', 'photography'
]).$;
if (categoryErr) return rej('invalid category param');
// Set category
Post.update({ _id: post._id }, {
$set: {
category: category,
is_category_verified: true
}
});
// Send response
res();
});

View file

@ -49,13 +49,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
return;
}
if (p.reply_to_id) {
await get(p.reply_to_id);
if (p.reply_id) {
await get(p.reply_id);
}
}
if (post.reply_to_id) {
await get(post.reply_to_id);
if (post.reply_id) {
await get(post.reply_id);
}
// Serialize

View file

@ -4,16 +4,17 @@
import $ from 'cafy';
import deepEqual = require('deep-equal');
import parse from '../../common/text';
import Post from '../../models/post';
import { isValidText } from '../../models/post';
import User from '../../models/user';
import { default as Post, IPost, isValidText } from '../../models/post';
import { default as User, IUser } from '../../models/user';
import { default as Channel, IChannel } from '../../models/channel';
import Following from '../../models/following';
import DriveFile from '../../models/drive-file';
import Watching from '../../models/post-watching';
import ChannelWatching from '../../models/channel-watching';
import serialize from '../../serializers/post';
import notify from '../../common/notify';
import watch from '../../common/watch-post';
import event from '../../event';
import { default as event, publishChannelStream } from '../../event';
import config from '../../../conf';
/**
@ -24,7 +25,7 @@ import config from '../../../conf';
* @param {any} app
* @return {Promise<any>}
*/
module.exports = (params, user, app) => new Promise(async (res, rej) => {
module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// Get 'text' parameter
const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$;
if (textErr) return rej('invalid text');
@ -43,9 +44,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// SELECT _id
const entity = await DriveFile.findOne({
_id: mediaId,
user_id: user._id
}, {
_id: true
'metadata.user_id': user._id
});
if (entity === null) {
@ -62,7 +61,8 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
const [repostId, repostIdErr] = $(params.repost_id).optional.id().$;
if (repostIdErr) return rej('invalid repost_id');
let repost = null;
let repost: IPost = null;
let isQuote = false;
if (repostId !== undefined) {
// Fetch repost to post
repost = await Post.findOne({
@ -84,43 +84,86 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
}
});
isQuote = text != null || files != null;
// 直近と同じRepost対象かつ引用じゃなかったらエラー
if (latestPost &&
latestPost.repost_id &&
latestPost.repost_id.equals(repost._id) &&
text === undefined && files === null) {
!isQuote) {
return rej('cannot repost same post that already reposted in your latest post');
}
// 直近がRepost対象かつ引用じゃなかったらエラー
if (latestPost &&
latestPost._id.equals(repost._id) &&
text === undefined && files === null) {
!isQuote) {
return rej('cannot repost your latest post');
}
}
// Get 'in_reply_to_post_id' parameter
const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$;
if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id');
// Get 'reply_id' parameter
const [replyId, replyIdErr] = $(params.reply_id).optional.id().$;
if (replyIdErr) return rej('invalid reply_id');
let inReplyToPost = null;
if (inReplyToPostId !== undefined) {
let reply: IPost = null;
if (replyId !== undefined) {
// Fetch reply
inReplyToPost = await Post.findOne({
_id: inReplyToPostId
reply = await Post.findOne({
_id: replyId
});
if (inReplyToPost === null) {
if (reply === null) {
return rej('in reply to post is not found');
}
// 返信対象が引用でないRepostだったらエラー
if (inReplyToPost.repost_id && !inReplyToPost.text && !inReplyToPost.media_ids) {
if (reply.repost_id && !reply.text && !reply.media_ids) {
return rej('cannot reply to repost');
}
}
// Get 'channel_id' parameter
const [channelId, channelIdErr] = $(params.channel_id).optional.id().$;
if (channelIdErr) return rej('invalid channel_id');
let channel: IChannel = null;
if (channelId !== undefined) {
// Fetch channel
channel = await Channel.findOne({
_id: channelId
});
if (channel === null) {
return rej('channel not found');
}
// 返信対象の投稿がこのチャンネルじゃなかったらダメ
if (reply && !channelId.equals(reply.channel_id)) {
return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません');
}
// Repost対象の投稿がこのチャンネルじゃなかったらダメ
if (repost && !channelId.equals(repost.channel_id)) {
return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません');
}
// 引用ではないRepostはダメ
if (repost && !isQuote) {
return rej('チャンネル内部では引用ではないRepostをすることはできません');
}
} else {
// 返信対象の投稿がチャンネルへの投稿だったらダメ
if (reply && reply.channel_id != null) {
return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません');
}
// Repost対象の投稿がチャンネルへの投稿だったらダメ
if (repost && repost.channel_id != null) {
return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません');
}
}
// Get 'poll' parameter
const [poll, pollErr] = $(params.poll).optional.strict.object()
.have('choices', $().array('string')
@ -148,12 +191,12 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
if (user.latest_post) {
if (deepEqual({
text: user.latest_post.text,
reply: user.latest_post.reply_to_id ? user.latest_post.reply_to_id.toString() : null,
reply: user.latest_post.reply_id ? user.latest_post.reply_id.toString() : null,
repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null,
media_ids: (user.latest_post.media_ids || []).map(id => id.toString())
}, {
text: text,
reply: inReplyToPost ? inReplyToPost._id.toString() : null,
reply: reply ? reply._id.toString() : null,
repost: repost ? repost._id.toString() : null,
media_ids: (files || []).map(file => file._id.toString())
})) {
@ -164,8 +207,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// 投稿を作成
const post = await Post.insert({
created_at: new Date(),
channel_id: channel ? channel._id : undefined,
index: channel ? channel.index + 1 : undefined,
media_ids: files ? files.map(file => file._id) : undefined,
reply_to_id: inReplyToPost ? inReplyToPost._id : undefined,
reply_id: reply ? reply._id : undefined,
repost_id: repost ? repost._id : undefined,
poll: poll,
text: text,
@ -179,8 +224,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// Reponse
res(postObj);
// -----------------------------------------------------------
// Post processes
//#region Post processes
User.update({ _id: user._id }, {
$set: {
@ -203,6 +247,8 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
}
}
// タイムラインへの投稿
if (!channel) {
// Publish event to myself's stream
event(user._id, 'post', postObj);
@ -220,6 +266,32 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// Publish event to followers stream
followers.forEach(following =>
event(following.follower_id, 'post', postObj));
}
// チャンネルへの投稿
if (channel) {
// Increment channel index(posts count)
Channel.update({ _id: channel._id }, {
$inc: {
index: 1
}
});
// Publish event to channel
publishChannelStream(channel._id, 'post', postObj);
// Get channel watchers
const watches = await ChannelWatching.find({
channel_id: channel._id,
// 削除されたドキュメントは除く
deleted_at: { $exists: false }
});
// チャンネルの視聴者(のタイムライン)に配信
watches.forEach(w => {
event(w.user_id, 'post', postObj);
});
}
// Increment my posts count
User.update({ _id: user._id }, {
@ -229,23 +301,23 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
});
// If has in reply to post
if (inReplyToPost) {
if (reply) {
// Increment replies count
Post.update({ _id: inReplyToPost._id }, {
Post.update({ _id: reply._id }, {
$inc: {
replies_count: 1
}
});
// 自分自身へのリプライでない限りは通知を作成
notify(inReplyToPost.user_id, user._id, 'reply', {
notify(reply.user_id, user._id, 'reply', {
post_id: post._id
});
// Fetch watchers
Watching
.find({
post_id: inReplyToPost._id,
post_id: reply._id,
user_id: { $ne: user._id },
// 削除されたドキュメントは除く
deleted_at: { $exists: false }
@ -265,10 +337,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// この投稿をWatchする
// TODO: ユーザーが「返信したときに自動でWatchする」設定を
// オフにしていた場合はしない
watch(user._id, inReplyToPost);
watch(user._id, reply);
// Add mention
addMention(inReplyToPost.user_id, 'reply');
addMention(reply.user_id, 'reply');
}
// If it is repost
@ -369,7 +441,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
if (mentionee == null) return;
// 既に言及されたユーザーに対する返信や引用repostの場合も無視
if (inReplyToPost && inReplyToPost.user_id.equals(mentionee._id)) return;
if (reply && reply.user_id.equals(mentionee._id)) return;
if (repost && repost.user_id.equals(mentionee._id)) return;
// Add mention
@ -406,4 +478,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
}
});
}
//#endregion
});

View file

@ -40,7 +40,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Issue query
const replies = await Post
.find({ reply_to_id: post._id }, {
.find({ reply_id: post._id }, {
limit: limit,
skip: offset,
sort: {

View file

@ -2,7 +2,9 @@
* Module dependencies
*/
import $ from 'cafy';
import rap from '@prezzemolo/rap';
import Post from '../../models/post';
import ChannelWatching from '../../models/channel-watching';
import getFriends from '../../common/get-friends';
import serialize from '../../serializers/post';
@ -14,36 +16,62 @@ import serialize from '../../serializers/post';
* @param {any} app
* @return {Promise<any>}
*/
module.exports = (params, user, app) => new Promise(async (res, rej) => {
module.exports = async (params, user, app) => {
// Get 'limit' parameter
const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
if (limitErr) return rej('invalid limit param');
if (limitErr) throw 'invalid limit param';
// Get 'since_id' parameter
const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
if (sinceIdErr) return rej('invalid since_id param');
if (sinceIdErr) throw 'invalid since_id param';
// Get 'max_id' parameter
const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
if (maxIdErr) return rej('invalid max_id param');
if (maxIdErr) throw 'invalid max_id param';
// Check if both of since_id and max_id is specified
if (sinceId && maxId) {
return rej('cannot set since_id and max_id');
throw 'cannot set since_id and max_id';
}
// ID list of the user $self and other users who the user follows
const followingIds = await getFriends(user._id);
const { followingIds, watchChannelIds } = await rap({
// ID list of the user itself and other users who the user follows
followingIds: getFriends(user._id),
// Watchしているチャンネルを取得
watchChannelIds: ChannelWatching.find({
user_id: user._id,
// 削除されたドキュメントは除く
deleted_at: { $exists: false }
}).then(watches => watches.map(w => w.channel_id))
});
// Construct query
//#region Construct query
const sort = {
_id: -1
};
const query = {
$or: [{
// フォローしている人のタイムラインへの投稿
user_id: {
$in: followingIds
},
// 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
$or: [{
channel_id: {
$exists: false
}
}, {
channel_id: null
}]
}, {
// Watchしているチャンネルへの投稿
channel_id: {
$in: watchChannelIds
}
}]
} as any;
if (sinceId) {
sort._id = 1;
query._id = {
@ -54,6 +82,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
$lt: maxId
};
}
//#endregion
// Issue query
const timeline = await Post
@ -63,7 +92,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
});
// Serialize
res(await Promise.all(timeline.map(async post =>
await serialize(post, user)
)));
});
const _timeline = await Promise.all(timeline.map(post => serialize(post, user)));
return _timeline;
};

View file

@ -48,7 +48,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
} as any;
if (reply != undefined) {
query.reply_to_id = reply ? { $exists: true, $ne: null } : null;
query.reply_id = reply ? { $exists: true, $ne: null } : null;
}
if (repost != undefined) {

View file

@ -0,0 +1,96 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Post from '../../models/post';
import User from '../../models/user';
import serialize from '../../serializers/user';
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'user_id' parameter
const [userId, userIdErr] = $(params.user_id).id().$;
if (userIdErr) return rej('invalid user_id param');
// Lookup user
const user = await User.findOne({
_id: userId
}, {
fields: {
_id: true
}
});
if (user === null) {
return rej('user not found');
}
// Fetch recent posts
const recentPosts = await Post.find({
user_id: user._id,
reply_id: {
$exists: true,
$ne: null
}
}, {
sort: {
_id: -1
},
limit: 1000,
fields: {
_id: false,
reply_id: true
}
});
// 投稿が少なかったら中断
if (recentPosts.length === 0) {
return res([]);
}
const replyTargetPosts = await Post.find({
_id: {
$in: recentPosts.map(p => p.reply_id)
},
user_id: {
$ne: user._id
}
}, {
fields: {
_id: false,
user_id: true
}
});
const repliedUsers = {};
// Extract replies from recent posts
replyTargetPosts.forEach(post => {
const userId = post.user_id.toString();
if (repliedUsers[userId]) {
repliedUsers[userId]++;
} else {
repliedUsers[userId] = 1;
}
});
// Calc peak
let peak = 0;
Object.keys(repliedUsers).forEach(user => {
if (repliedUsers[user] > peak) peak = repliedUsers[user];
});
// Sort replies by frequency
const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]);
// Lookup top 10 replies
const topRepliedUsers = repliedUsersSorted.slice(0, 10);
// Make replies object (includes weights)
const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({
user: await serialize(user, me, { detail: true }),
weight: repliedUsers[user] / peak
})));
// Response
res(repliesObj);
});

View file

@ -85,7 +85,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
}
if (!includeReplies) {
query.reply_to_id = null;
query.reply_id = null;
}
if (withMedia) {

View file

@ -25,6 +25,10 @@ class MisskeyEvent {
this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
}
public publishChannelStream(channelId: ID, type: string, value?: any): void {
this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value);
}
private publish(channel: string, type: string, value?: any): void {
const message = value == null ?
{ type: type } :
@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev);
export const publishPostStream = ev.publishPostStream.bind(ev);
export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
export const publishChannelStream = ev.publishChannelStream.bind(ev);

View file

@ -2,7 +2,7 @@ import db from '../../db/mongodb';
const collection = db.get('access_tokens');
(collection as any).index('token'); // fuck type definition
(collection as any).index('hash'); // fuck type definition
(collection as any).createIndex('token'); // fuck type definition
(collection as any).createIndex('hash'); // fuck type definition
export default collection as any; // fuck type definition

View file

@ -2,9 +2,9 @@ import db from '../../db/mongodb';
const collection = db.get('apps');
(collection as any).index('name_id'); // fuck type definition
(collection as any).index('name_id_lower'); // fuck type definition
(collection as any).index('secret'); // fuck type definition
(collection as any).createIndex('name_id'); // fuck type definition
(collection as any).createIndex('name_id_lower'); // fuck type definition
(collection as any).createIndex('secret'); // fuck type definition
export default collection as any; // fuck type definition

View file

@ -0,0 +1,3 @@
import db from '../../db/mongodb';
export default db.get('channel_watching') as any; // fuck type definition

14
src/api/models/channel.ts Normal file
View file

@ -0,0 +1,14 @@
import * as mongo from 'mongodb';
import db from '../../db/mongodb';
const collection = db.get('channels');
export default collection as any; // fuck type definition
export type IChannel = {
_id: mongo.ObjectID;
created_at: Date;
title: string;
user_id: mongo.ObjectID;
index: number;
};

View file

@ -1,11 +1,22 @@
import db from '../../db/mongodb';
import * as mongodb from 'mongodb';
import monkDb, { nativeDbConn } from '../../db/mongodb';
const collection = db.get('drive_files');
const collection = monkDb.get('drive_files.files');
(collection as any).index('hash'); // fuck type definition
(collection as any).createIndex('hash'); // fuck type definition
export default collection as any; // fuck type definition
const getGridFSBucket = async (): Promise<mongodb.GridFSBucket> => {
const db = await nativeDbConn();
const bucket = new mongodb.GridFSBucket(db, {
bucketName: 'drive_files'
});
return bucket;
};
export { getGridFSBucket };
export function validateFileName(name: string): boolean {
return (
(name.trim().length > 0) &&

View file

@ -1,3 +1,8 @@
import * as mongo from 'mongodb';
import db from '../../db/mongodb';
export default db.get('notifications') as any; // fuck type definition
export interface INotification {
_id: mongo.ObjectID;
}

View file

@ -1,3 +1,5 @@
import * as mongo from 'mongodb';
import db from '../../db/mongodb';
export default db.get('posts') as any; // fuck type definition
@ -5,3 +7,16 @@ export default db.get('posts') as any; // fuck type definition
export function isValidText(text: string): boolean {
return text.length <= 1000 && text.trim() != '';
}
export type IPost = {
_id: mongo.ObjectID;
channel_id: mongo.ObjectID;
created_at: Date;
media_ids: mongo.ObjectID[];
reply_id: mongo.ObjectID;
repost_id: mongo.ObjectID;
poll: {}; // todo
text: string;
user_id: mongo.ObjectID;
app_id: mongo.ObjectID;
};

View file

@ -1,9 +1,12 @@
import * as mongo from 'mongodb';
import db from '../../db/mongodb';
import { IPost } from './post';
const collection = db.get('users');
(collection as any).index('username'); // fuck type definition
(collection as any).index('token'); // fuck type definition
(collection as any).createIndex('username'); // fuck type definition
(collection as any).createIndex('token'); // fuck type definition
export default collection as any; // fuck type definition
@ -31,6 +34,50 @@ export function isValidBirthday(birthday: string): boolean {
return typeof birthday == 'string' && /^([0-9]{4})\-([0-9]{2})-([0-9]{2})$/.test(birthday);
}
export interface IUser {
export type IUser = {
_id: mongo.ObjectID;
created_at: Date;
email: string;
followers_count: number;
following_count: number;
links: string[];
name: string;
password: string;
posts_count: number;
drive_capacity: number;
username: string;
username_lower: string;
token: string;
avatar_id: mongo.ObjectID;
banner_id: mongo.ObjectID;
data: any;
twitter: {
access_token: string;
access_token_secret: string;
user_id: string;
screen_name: string;
};
line: {
user_id: string;
};
description: string;
profile: {
location: string;
birthday: string; // 'YYYY-MM-DD'
tags: string[];
};
last_used_at: Date;
latest_post: IPost;
pinned_post_id: mongo.ObjectID;
is_pro: boolean;
is_suspended: boolean;
keywords: string[];
};
export function init(user): IUser {
user._id = new mongo.ObjectID(user._id);
user.avatar_id = new mongo.ObjectID(user.avatar_id);
user.banner_id = new mongo.ObjectID(user.banner_id);
user.pinned_post_id = new mongo.ObjectID(user.pinned_post_id);
return user;
}

View file

@ -1,6 +1,6 @@
import * as express from 'express';
import * as bcrypt from 'bcryptjs';
import User from '../models/user';
import { default as User, IUser } from '../models/user';
import Signin from '../models/signin';
import serialize from '../serializers/signin';
import event from '../event';
@ -23,7 +23,7 @@ export default async (req: express.Request, res: express.Response) => {
}
// Fetch user
const user = await User.findOne({
const user: IUser = await User.findOne({
username_lower: username.toLowerCase()
}, {
fields: {

View file

@ -1,10 +1,10 @@
import * as express from 'express';
import * as bcrypt from 'bcryptjs';
import rndstr from 'rndstr';
import recaptcha = require('recaptcha-promise');
import User from '../models/user';
import { default as User, IUser } from '../models/user';
import { validateUsername, validatePassword } from '../models/user';
import serialize from '../serializers/user';
import generateUserToken from '../common/generate-native-user-token';
import config from '../../conf';
recaptcha.init({
@ -58,10 +58,10 @@ export default async (req: express.Request, res: express.Response) => {
const hash = bcrypt.hashSync(password, salt);
// Generate secret
const secret = `!${rndstr('a-zA-Z0-9', 32)}`;
const secret = generateUserToken();
// Create account
const account = await User.insert({
const account: IUser = await User.insert({
token: secret,
avatar_id: null,
banner_id: null,

View file

@ -0,0 +1,66 @@
/**
* Module dependencies
*/
import * as mongo from 'mongodb';
import deepcopy = require('deepcopy');
import { IUser } from '../models/user';
import { default as Channel, IChannel } from '../models/channel';
import Watching from '../models/channel-watching';
/**
* Serialize a channel
*
* @param channel target
* @param me? serializee
* @return response
*/
export default (
channel: string | mongo.ObjectID | IChannel,
me?: string | mongo.ObjectID | IUser
) => new Promise<any>(async (resolve, reject) => {
let _channel: any;
// Populate the channel if 'channel' is ID
if (mongo.ObjectID.prototype.isPrototypeOf(channel)) {
_channel = await Channel.findOne({
_id: channel
});
} else if (typeof channel === 'string') {
_channel = await Channel.findOne({
_id: new mongo.ObjectID(channel)
});
} else {
_channel = deepcopy(channel);
}
// Rename _id to id
_channel.id = _channel._id;
delete _channel._id;
// Remove needless properties
delete _channel.user_id;
// Me
const meId: mongo.ObjectID = me
? mongo.ObjectID.prototype.isPrototypeOf(me)
? me as mongo.ObjectID
: typeof me === 'string'
? new mongo.ObjectID(me)
: (me as IUser)._id
: null;
if (me) {
//#region Watchしているかどうか
const watch = await Watching.findOne({
user_id: meId,
channel_id: _channel.id,
deleted_at: { $exists: false }
});
_channel.is_watching = watch !== null;
//#endregion
}
resolve(_channel);
});

View file

@ -31,44 +31,40 @@ export default (
if (mongo.ObjectID.prototype.isPrototypeOf(file)) {
_file = await DriveFile.findOne({
_id: file
}, {
fields: {
data: false
}
});
} else if (typeof file === 'string') {
_file = await DriveFile.findOne({
_id: new mongo.ObjectID(file)
}, {
fields: {
data: false
}
});
} else {
_file = deepcopy(file);
}
// Rename _id to id
_file.id = _file._id;
delete _file._id;
if (!_file) return reject('invalid file arg.');
delete _file.data;
// rendered target
let _target: any = {};
_file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`;
_target.id = _file._id;
_target.created_at = _file.uploadDate;
if (opts.detail && _file.folder_id) {
_target = Object.assign(_target, _file.metadata);
_target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`;
if (opts.detail && _target.folder_id) {
// Populate folder
_file.folder = await serializeDriveFolder(_file.folder_id, {
_target.folder = await serializeDriveFolder(_target.folder_id, {
detail: true
});
}
if (opts.detail && _file.tags) {
if (opts.detail && _target.tags) {
// Populate tags
_file.tags = await _file.tags.map(async (tag: any) =>
_target.tags = await _target.tags.map(async (tag: any) =>
await serializeDriveTag(tag)
);
}
resolve(_file);
resolve(_target);
});

View file

@ -44,7 +44,7 @@ const self = (
});
const childFilesCount = await DriveFile.count({
folder_id: _folder.id
'metadata.folder_id': _folder.id
});
_folder.folders_count = childFoldersCount;

View file

@ -3,33 +3,45 @@
*/
import * as mongo from 'mongodb';
import deepcopy = require('deepcopy');
import Post from '../models/post';
import { default as Post, IPost } from '../models/post';
import Reaction from '../models/post-reaction';
import { IUser } from '../models/user';
import Vote from '../models/poll-vote';
import serializeApp from './app';
import serializeChannel from './channel';
import serializeUser from './user';
import serializeDriveFile from './drive-file';
import parse from '../common/text';
import rap from '@prezzemolo/rap';
/**
* Serialize a post
*
* @param {any} post
* @param {any} me?
* @param {any} options?
* @return {Promise<any>}
* @param post target
* @param me? serializee
* @param options? serialize options
* @return response
*/
const self = (
post: any,
me?: any,
const self = async (
post: string | mongo.ObjectID | IPost,
me?: string | mongo.ObjectID | IUser,
options?: {
detail: boolean
}
) => new Promise<any>(async (resolve, reject) => {
) => {
const opts = options || {
detail: true,
};
// Me
const meId: mongo.ObjectID = me
? mongo.ObjectID.prototype.isPrototypeOf(me)
? me as mongo.ObjectID
: typeof me === 'string'
? new mongo.ObjectID(me)
: (me as IUser)._id
: null;
let _post: any;
// Populate the post if 'post' is ID
@ -45,6 +57,8 @@ const self = (
_post = deepcopy(post);
}
if (!_post) throw 'invalid post arg.';
const id = _post._id;
// Rename _id to id
@ -59,62 +73,120 @@ const self = (
}
// Populate user
_post.user = await serializeUser(_post.user_id, me);
_post.user = serializeUser(_post.user_id, meId);
// Populate app
if (_post.app_id) {
_post.app = await serializeApp(_post.app_id);
_post.app = serializeApp(_post.app_id);
}
// Populate channel
if (_post.channel_id) {
_post.channel = serializeChannel(_post.channel_id);
}
if (_post.media_ids) {
// Populate media
_post.media = await Promise.all(_post.media_ids.map(async fileId =>
await serializeDriveFile(fileId)
if (_post.media_ids) {
_post.media = Promise.all(_post.media_ids.map(fileId =>
serializeDriveFile(fileId)
));
}
if (_post.reply_to_id && opts.detail) {
// When requested a detailed post data
if (opts.detail) {
// Get previous post info
_post.prev = (async () => {
const prev = await Post.findOne({
user_id: _post.user_id,
_id: {
$lt: id
}
}, {
fields: {
_id: true
},
sort: {
_id: -1
}
});
return prev ? prev._id : null;
})();
// Get next post info
_post.next = (async () => {
const next = await Post.findOne({
user_id: _post.user_id,
_id: {
$gt: id
}
}, {
fields: {
_id: true
},
sort: {
_id: 1
}
});
return next ? next._id : null;
})();
if (_post.reply_id) {
// Populate reply to post
_post.reply_to = await self(_post.reply_to_id, me, {
_post.reply = self(_post.reply_id, meId, {
detail: false
});
}
if (_post.repost_id && opts.detail) {
if (_post.repost_id) {
// Populate repost
_post.repost = await self(_post.repost_id, me, {
_post.repost = self(_post.repost_id, meId, {
detail: _post.text == null
});
}
// Poll
if (me && _post.poll && opts.detail) {
if (meId && _post.poll) {
_post.poll = (async (poll) => {
const vote = await Vote
.findOne({
user_id: me._id,
user_id: meId,
post_id: id
});
if (vote != null) {
_post.poll.choices.filter(c => c.id == vote.choice)[0].is_voted = true;
const myChoice = poll.choices
.filter(c => c.id == vote.choice)[0];
myChoice.is_voted = true;
}
return poll;
})(_post.poll);
}
// Fetch my reaction
if (me && opts.detail) {
if (meId) {
_post.my_reaction = (async () => {
const reaction = await Reaction
.findOne({
user_id: me._id,
user_id: meId,
post_id: id,
deleted_at: { $exists: false }
});
if (reaction) {
_post.my_reaction = reaction.reaction;
return reaction.reaction;
}
return null;
})();
}
}
resolve(_post);
});
// resolve promises in _post object
_post = await rap(_post);
return _post;
};
export default self;

View file

@ -3,22 +3,24 @@
*/
import * as mongo from 'mongodb';
import deepcopy = require('deepcopy');
import User from '../models/user';
import { default as User, IUser } from '../models/user';
import serializePost from './post';
import Following from '../models/following';
import getFriends from '../common/get-friends';
import config from '../../conf';
import rap from '@prezzemolo/rap';
/**
* Serialize a user
*
* @param {any} user
* @param {any} me?
* @param {any} options?
* @return {Promise<any>}
* @param user target
* @param me? serializee
* @param options? serialize options
* @return response
*/
export default (
user: any,
me?: any,
user: string | mongo.ObjectID | IUser,
me?: string | mongo.ObjectID | IUser,
options?: {
detail?: boolean,
includeSecrets?: boolean
@ -36,7 +38,9 @@ export default (
data: false
} : {
data: false,
profile: false
profile: false,
keywords: false,
domains: false
};
// Populate the user if 'user' is ID
@ -52,14 +56,16 @@ export default (
_user = deepcopy(user);
}
if (!_user) return reject('invalid user arg.');
// Me
if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) {
if (typeof me === 'string') {
me = new mongo.ObjectID(me);
} else {
me = me._id;
}
}
const meId: mongo.ObjectID = me
? mongo.ObjectID.prototype.isPrototypeOf(me)
? me as mongo.ObjectID
: typeof me === 'string'
? new mongo.ObjectID(me)
: (me as IUser)._id
: null;
// Rename _id to id
_user.id = _user._id;
@ -76,6 +82,7 @@ export default (
delete _user.twitter.access_token;
delete _user.twitter.access_token_secret;
}
delete _user.line;
// Visible via only the official client
if (!opts.includeSecrets) {
@ -91,50 +98,64 @@ export default (
? `${config.drive_url}/${_user.banner_id}`
: null;
if (!me || !me.equals(_user.id) || !opts.detail) {
if (!meId || !meId.equals(_user.id) || !opts.detail) {
delete _user.avatar_id;
delete _user.banner_id;
delete _user.drive_capacity;
}
if (me && !me.equals(_user.id)) {
if (meId && !meId.equals(_user.id)) {
// If the user is following
_user.is_following = (async () => {
const follow = await Following.findOne({
follower_id: me,
follower_id: meId,
followee_id: _user.id,
deleted_at: { $exists: false }
});
_user.is_following = follow !== null;
return follow !== null;
})();
// If the user is followed
_user.is_followed = (async () => {
const follow2 = await Following.findOne({
follower_id: _user.id,
followee_id: me,
followee_id: meId,
deleted_at: { $exists: false }
});
_user.is_followed = follow2 !== null;
return follow2 !== null;
})();
}
if (me && !me.equals(_user.id) && opts.detail) {
const myFollowingIds = await getFriends(me);
if (opts.detail) {
if (_user.pinned_post_id) {
// Populate pinned post
_user.pinned_post = serializePost(_user.pinned_post_id, meId, {
detail: true
});
}
if (meId && !meId.equals(_user.id)) {
const myFollowingIds = await getFriends(meId);
// Get following you know count
const followingYouKnowCount = await Following.count({
_user.following_you_know_count = Following.count({
followee_id: { $in: myFollowingIds },
follower_id: _user.id,
deleted_at: { $exists: false }
});
_user.following_you_know_count = followingYouKnowCount;
// Get followers you know count
const followersYouKnowCount = await Following.count({
_user.followers_you_know_count = Following.count({
followee_id: _user.id,
follower_id: { $in: myFollowingIds },
deleted_at: { $exists: false }
});
_user.followers_you_know_count = followersYouKnowCount;
}
}
// resolve promises in _user object
_user = await rap(_user);
resolve(_user);
});

View file

@ -19,7 +19,12 @@ app.disable('x-powered-by');
app.set('etag', false);
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json({
type: ['application/json', 'text/plain']
type: ['application/json', 'text/plain'],
verify: (req, res, buf, encoding) => {
if (buf && buf.length) {
(req as any).rawBody = buf.toString(encoding || 'utf8');
}
}
}));
app.use(cors({
origin: true
@ -54,4 +59,6 @@ app.use((req, res, next) => {
require('./service/github')(app);
require('./service/twitter')(app);
require('./bot/interfaces/line')(app);
module.exports = app;

View file

@ -111,12 +111,12 @@ module.exports = async (app: express.Application) => {
handler.on('watch', event => {
const sender = event.sender;
post(`Starred by **${sender.login}**`);
post(`⭐️ Starred by **${sender.login}** ⭐️`);
});
handler.on('fork', event => {
const repo = event.forkee;
post(`Forked:\n${repo.html_url}`);
post(`🍴 Forked:\n${repo.html_url} 🍴`);
});
handler.on('pull_request', event => {

12
src/api/stream/channel.ts Normal file
View file

@ -0,0 +1,12 @@
import * as websocket from 'websocket';
import * as redis from 'redis';
export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void {
const channel = request.resourceURL.query.channel;
// Subscribe channel stream
subscriber.subscribe(`misskey:channel-stream:${channel}`);
subscriber.on('message', (_, data) => {
connection.send(data);
});
}

View file

@ -2,7 +2,9 @@ import * as websocket from 'websocket';
import * as redis from 'redis';
import * as debug from 'debug';
import User from '../models/user';
import serializePost from '../serializers/post';
import readNotification from '../common/read-notification';
const log = debug('misskey');
@ -35,6 +37,20 @@ export default function homeStream(request: websocket.request, connection: webso
const msg = JSON.parse(data.utf8Data);
switch (msg.type) {
case 'alive':
// Update lastUsedAt
User.update({ _id: user._id }, {
$set: {
last_used_at: new Date()
}
});
break;
case 'read_notification':
if (!msg.id) return;
readNotification(user._id, msg.id);
break;
case 'capture':
if (!msg.id) return;
const postId = msg.id;

View file

@ -14,7 +14,6 @@ export default function homeStream(request: websocket.request, connection: webso
ev.addListener('stats', onStats);
connection.on('close', () => {
console.log('yooo');
ev.removeListener('stats', onStats);
});
}

View file

@ -2,13 +2,14 @@ import * as http from 'http';
import * as websocket from 'websocket';
import * as redis from 'redis';
import config from '../conf';
import User from './models/user';
import { default as User, IUser } from './models/user';
import AccessToken from './models/access-token';
import isNativeToken from './common/is-native-token';
import homeStream from './stream/home';
import messagingStream from './stream/messaging';
import serverStream from './stream/server';
import channelStream from './stream/channel';
module.exports = (server: http.Server) => {
/**
@ -26,14 +27,6 @@ module.exports = (server: http.Server) => {
return;
}
const user = await authenticate(connection, request.resourceURL.query.i);
if (user == null) {
connection.send('authentication-failed');
connection.close();
return;
}
// Connect to Redis
const subscriber = redis.createClient(
config.redis.port, config.redis.host);
@ -43,6 +36,19 @@ module.exports = (server: http.Server) => {
subscriber.quit();
});
if (request.resourceURL.pathname === '/channel') {
channelStream(request, connection, subscriber);
return;
}
const user = await authenticate(request.resourceURL.query.i);
if (user == null) {
connection.send('authentication-failed');
connection.close();
return;
}
const channel =
request.resourceURL.pathname === '/' ? homeStream :
request.resourceURL.pathname === '/messaging' ? messagingStream :
@ -56,7 +62,11 @@ module.exports = (server: http.Server) => {
});
};
function authenticate(connection: websocket.connection, token: string): Promise<any> {
/**
*
* @param token
*/
function authenticate(token: string): Promise<IUser> {
if (token == null) {
return Promise.resolve(null);
}
@ -64,8 +74,7 @@ function authenticate(connection: websocket.connection, token: string): Promise<
return new Promise(async (resolve, reject) => {
if (isNativeToken(token)) {
// Fetch user
// SELECT _id
const user = await User
const user: IUser = await User
.findOne({
token: token
});
@ -81,13 +90,8 @@ function authenticate(connection: websocket.connection, token: string): Promise<
}
// Fetch user
// SELECT _id
const user = await User
.findOne({ _id: accessToken.user_id }, {
fields: {
_id: true
}
});
const user: IUser = await User
.findOne({ _id: accessToken.user_id });
resolve(user);
}

View file

@ -1,5 +1,15 @@
const summarize = post => {
let summary = post.text ? post.text : '';
/**
* 稿
* @param {*} post 稿
*/
const summarize = (post: any): string => {
let summary = '';
// チャンネル
summary += post.channel ? `${post.channel.title}:` : '';
// 本文
summary += post.text ? post.text : '';
// メディアが添付されているとき
if (post.media) {
@ -12,9 +22,9 @@ const summarize = post => {
}
// 返信のとき
if (post.reply_to_id) {
if (post.reply_to) {
summary += ` RE: ${summarize(post.reply_to)}`;
if (post.reply_id) {
if (post.reply) {
summary += ` RE: ${summarize(post.reply)}`;
} else {
summary += ' RE: ...';
}

View file

@ -0,0 +1,12 @@
import { IUser } from '../api/models/user';
/**
*
* @param user
*/
export default function(user: IUser): string {
return `${user.name} (@${user.username})\n` +
`${user.posts_count}投稿、${user.following_count}フォロー、${user.followers_count}フォロワー\n` +
`場所: ${user.profile.location}、誕生日: ${user.profile.birthday}\n` +
`${user.description}`;
}

268
src/common/othello.ts Normal file
View file

@ -0,0 +1,268 @@
const BOARD_SIZE = 8;
export default class Othello {
public board: Array<Array<'black' | 'white'>>;
/**
*
*/
constructor() {
this.board = [
[null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null],
[null, null, null, 'black', 'white', null, null, null],
[null, null, null, 'white', 'black', null, null, null],
[null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null],
[null, null, null, null, null, null, null, null]
];
}
public setByNumber(color, n) {
const ps = this.getPattern(color);
this.set(color, ps[n][0], ps[n][1]);
}
private write(color, x, y) {
this.board[y][x] = color;
}
/**
*
*/
public set(color, x, y) {
this.write(color, x, y);
const reverses = this.getReverse(color, x, y);
reverses.forEach(r => {
switch (r[0]) {
case 0: // 上
for (let c = 0, _y = y - 1; c < r[1]; c++, _y--) {
this.write(color, x, _y);
}
break;
case 1: // 右上
for (let c = 0, i = 1; c < r[1]; c++, i++) {
this.write(color, x + i, y - i);
}
break;
case 2: // 右
for (let c = 0, _x = x + 1; c < r[1]; c++, _x++) {
this.write(color, _x, y);
}
break;
case 3: // 右下
for (let c = 0, i = 1; c < r[1]; c++, i++) {
this.write(color, x + i, y + i);
}
break;
case 4: // 下
for (let c = 0, _y = y + 1; c < r[1]; c++, _y++) {
this.write(color, x, _y);
}
break;
case 5: // 左下
for (let c = 0, i = 1; c < r[1]; c++, i++) {
this.write(color, x - i, y + i);
}
break;
case 6: // 左
for (let c = 0, _x = x - 1; c < r[1]; c++, _x--) {
this.write(color, _x, y);
}
break;
case 7: // 左上
for (let c = 0, i = 1; c < r[1]; c++, i++) {
this.write(color, x - i, y - i);
}
break;
}
});
}
/**
*
*/
public getPattern(myColor): number[][] {
const result = [];
this.board.forEach((stones, y) => stones.forEach((stone, x) => {
if (stone != null) return;
if (this.canReverse(myColor, x, y)) result.push([x, y]);
}));
return result;
}
/**
* (1)
*/
public canReverse(myColor, targetx, targety): boolean {
return this.getReverse(myColor, targetx, targety) !== null;
}
private getReverse(myColor, targetx, targety): number[] {
const opponentColor = myColor == 'black' ? 'white' : 'black';
const createIterater = () => {
let opponentStoneFound = false;
let breaked = false;
return (x, y): any => {
if (breaked) {
return;
} else if (this.board[y][x] == myColor && opponentStoneFound) {
return true;
} else if (this.board[y][x] == myColor && !opponentStoneFound) {
breaked = true;
} else if (this.board[y][x] == opponentColor) {
opponentStoneFound = true;
} else {
breaked = true;
}
};
};
const res = [];
let iterate;
// 上
iterate = createIterater();
for (let c = 0, y = targety - 1; y >= 0; c++, y--) {
if (iterate(targetx, y)) {
res.push([0, c]);
break;
}
}
// 右上
iterate = createIterater();
for (let c = 0, i = 1; i <= Math.min(BOARD_SIZE - targetx, targety); c++, i++) {
if (iterate(targetx + i, targety - i)) {
res.push([1, c]);
break;
}
}
// 右
iterate = createIterater();
for (let c = 0, x = targetx + 1; x < BOARD_SIZE; c++, x++) {
if (iterate(x, targety)) {
res.push([2, c]);
break;
}
}
// 右下
iterate = createIterater();
for (let c = 0, i = 1; i < Math.min(BOARD_SIZE - targetx, BOARD_SIZE - targety); c++, i++) {
if (iterate(targetx + i, targety + i)) {
res.push([3, c]);
break;
}
}
// 下
iterate = createIterater();
for (let c = 0, y = targety + 1; y < BOARD_SIZE; c++, y++) {
if (iterate(targetx, y)) {
res.push([4, c]);
break;
}
}
// 左下
iterate = createIterater();
for (let c = 0, i = 1; i < Math.min(targetx, BOARD_SIZE - targety); c++, i++) {
if (iterate(targetx - i, targety + i)) {
res.push([5, c]);
break;
}
}
// 左
iterate = createIterater();
for (let c = 0, x = targetx - 1; x >= 0; c++, x--) {
if (iterate(x, targety)) {
res.push([6, c]);
break;
}
}
// 左上
iterate = createIterater();
for (let c = 0, i = 1; i <= Math.min(targetx, targety); c++, i++) {
if (iterate(targetx - i, targety - i)) {
res.push([7, c]);
break;
}
}
return res.length === 0 ? null : res;
}
public toString(): string {
//return this.board.map(row => row.map(state => state === 'black' ? '●' : state === 'white' ? '○' : '┼').join('')).join('\n');
return this.board.map(row => row.map(state => state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : '🔹').join('')).join('\n');
}
public toPatternString(color): string {
//const num = ['', '', '', '', '', '', '', '', '', ''];
const num = ['0⃣', '1⃣', '2⃣', '3⃣', '4⃣', '5⃣', '6⃣', '7⃣', '8⃣', '9⃣', '🔟', '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🍍'];
const pattern = this.getPattern(color);
return this.board.map((row, y) => row.map((state, x) => {
const i = pattern.findIndex(p => p[0] == x && p[1] == y);
//return state === 'black' ? '●' : state === 'white' ? '○' : i != -1 ? num[i] : '┼';
return state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : i != -1 ? num[i] : '🔹';
}).join('')).join('\n');
}
}
export function ai(color: string, othello: Othello) {
const opponentColor = color == 'black' ? 'white' : 'black';
function think() {
// 打てる場所を取得
const ps = othello.getPattern(color);
if (ps.length > 0) { // 打てる場所がある場合
// 角を取得
const corners = ps.filter(p =>
// 左上
(p[0] == 0 && p[1] == 0) ||
// 右上
(p[0] == (BOARD_SIZE - 1) && p[1] == 0) ||
// 右下
(p[0] == (BOARD_SIZE - 1) && p[1] == (BOARD_SIZE - 1)) ||
// 左下
(p[0] == 0 && p[1] == (BOARD_SIZE - 1))
);
if (corners.length > 0) { // どこかしらの角に打てる場合
// 打てる角からランダムに選択して打つ
const p = corners[Math.floor(Math.random() * corners.length)];
othello.set(color, p[0], p[1]);
} else { // 打てる角がない場合
// 打てる場所からランダムに選択して打つ
const p = ps[Math.floor(Math.random() * ps.length)];
othello.set(color, p[0], p[1]);
}
// 相手の打つ場所がない場合続けてAIのターン
if (othello.getPattern(opponentColor).length === 0) {
think();
}
}
}
think();
}

View file

@ -68,6 +68,13 @@ type Source = {
hook_secret: string;
username: string;
};
line_bot?: {
channel_secret: string;
channel_access_token: string;
};
analysis?: {
mecab_command?: string;
};
};
/**
@ -81,6 +88,7 @@ type Mixin = {
api_url: string;
auth_url: string;
about_url: string;
ch_url: string;
stats_url: string;
status_url: string;
dev_url: string;
@ -115,6 +123,7 @@ export default function load() {
mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://'));
mixin.api_url = `${mixin.scheme}://api.${mixin.host}`;
mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`;
mixin.ch_url = `${mixin.scheme}://ch.${mixin.host}`;
mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`;
mixin.about_url = `${mixin.scheme}://about.${mixin.host}`;
mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`;

View file

@ -1,5 +1,4 @@
{
"themeColor": "#87bb35",
"themeColorForeground": "#fff",
"idea": ["#f13049", "#f43636"]
"themeColor": "#f43636",
"themeColorForeground": "#fff"
}

View file

@ -1,11 +1,38 @@
import * as mongo from 'monk';
import config from '../conf';
const uri = config.mongodb.user && config.mongodb.pass
? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`
: `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`;
? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`
: `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`;
/**
* monk
*/
import * as mongo from 'monk';
const db = mongo(uri);
export default db;
/**
* MongoDB native module (officialy)
*/
import * as mongodb from 'mongodb';
let mdb: mongodb.Db;
const nativeDbConn = async (): Promise<mongodb.Db> => {
if (mdb) return mdb;
const db = await ((): Promise<mongodb.Db> => new Promise((resolve, reject) => {
mongodb.MongoClient.connect(uri, (e, db) => {
if (e) return reject(e);
resolve(db);
});
}))();
mdb = db;
return db;
};
export { nativeDbConn };

View file

@ -52,11 +52,11 @@ block content
td Number
td 返信数
tr.optional
td reply_to
td reply
td: a(href='./post', target='_blank') Post
td 返信先の投稿
tr.nullable
td reply_to_id
td reply_id
td ID
td 返信先の投稿のID
tr.optional
@ -90,7 +90,7 @@ block content
{
"created_at": "2016-12-10T00:28:50.114Z",
"media_ids": null,
"reply_to_id": "584a16b15860fc52320137e3",
"reply_id": "584a16b15860fc52320137e3",
"repost_id": null,
"text": "小日向美穂だぞ!",
"user_id": "5848bf7764e572683f4402f8",
@ -117,10 +117,10 @@ block content
"is_following": true,
"is_followed": true
},
"reply_to": {
"reply": {
"created_at": "2016-12-09T02:28:01.563Z",
"media_ids": null,
"reply_to_id": "5849d35e547e4249be329884",
"reply_id": "5849d35e547e4249be329884",
"repost_id": null,
"text": "アイコン小日向美穂?",
"user_id": "57d01a501fdf2d07be417afe",

View file

@ -9,7 +9,7 @@ import * as cors from 'cors';
import * as mongodb from 'mongodb';
import * as gm from 'gm';
import File from '../api/models/drive-file';
import DriveFile, { getGridFSBucket } from '../api/models/drive-file';
/**
* Init app
@ -97,17 +97,28 @@ app.get('/:id', async (req, res) => {
return;
}
const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) });
const fileId = new mongodb.ObjectID(req.params.id);
const file = await DriveFile.findOne({ _id: fileId });
if (file == null) {
res.status(404).sendFile(`${__dirname} / assets / dummy.png`);
return;
} else if (file.data == null) {
res.sendStatus(400);
res.status(404).sendFile(`${__dirname}/assets/dummy.png`);
return;
}
send(file.data.buffer, file.type, req, res);
const bucket = await getGridFSBucket();
const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => {
const chunks = [];
const readableStream = bucket.openDownloadStream(id);
readableStream.on('data', chunk => {
chunks.push(chunk);
});
readableStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
}))(fileId);
send(buffer, file.metadata.type, req, res);
});
app.get('/:id/:name', async (req, res) => {
@ -117,17 +128,28 @@ app.get('/:id/:name', async (req, res) => {
return;
}
const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) });
const fileId = new mongodb.ObjectID(req.params.id);
const file = await DriveFile.findOne({ _id: fileId });
if (file == null) {
res.status(404).sendFile(`${__dirname}/assets/dummy.png`);
return;
} else if (file.data == null) {
res.sendStatus(400);
return;
}
send(file.data.buffer, file.type, req, res);
const bucket = await getGridFSBucket();
const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => {
const chunks = [];
const readableStream = bucket.openDownloadStream(id);
readableStream.on('data', chunk => {
chunks.push(chunk);
});
readableStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
}))(fileId);
send(buffer, file.metadata.type, req, res);
});
module.exports = app;

View file

@ -0,0 +1,49 @@
const bayes = require('./naive-bayes.js');
const MeCab = require('./mecab');
import Post from '../../api/models/post';
/**
* 稿稿
*/
export default class Categorizer {
private classifier: any;
private mecab: any;
constructor() {
this.mecab = new MeCab();
// BIND -----------------------------------
this.tokenizer = this.tokenizer.bind(this);
}
private tokenizer(text: string) {
const tokens = this.mecab.parseSync(text)
// 名詞だけに制限
.filter(token => token[1] === '名詞')
// 取り出し
.map(token => token[0]);
return tokens;
}
public async init() {
this.classifier = bayes({
tokenizer: this.tokenizer
});
// 訓練データ取得
const verifiedPosts = await Post.find({
is_category_verified: true
});
// 学習
verifiedPosts.forEach(post => {
this.classifier.learn(post.text, post.category);
});
}
public async predict(text) {
return this.classifier.categorize(text);
}
}

View file

@ -0,0 +1,120 @@
import * as URL from 'url';
import Post from '../../api/models/post';
import User from '../../api/models/user';
import parse from '../../api/common/text';
process.on('unhandledRejection', console.dir);
function tokenize(text: string) {
if (text == null) return [];
// パース
const ast = parse(text);
const domains = ast
// URLを抽出
.filter(t => t.type == 'url' || t.type == 'link')
.map(t => URL.parse(t.url).hostname);
return domains;
}
// Fetch all users
User.find({}, {
fields: {
_id: true
}
}).then(users => {
let i = -1;
const x = cb => {
if (++i == users.length) return cb();
extractDomainsOne(users[i]._id).then(() => x(cb), err => {
console.error(err);
setTimeout(() => {
i--;
x(cb);
}, 1000);
});
};
x(() => {
console.log('complete');
});
});
function extractDomainsOne(id) {
return new Promise(async (resolve, reject) => {
process.stdout.write(`extracting domains of ${id} ...`);
// Fetch recent posts
const recentPosts = await Post.find({
user_id: id,
text: {
$exists: true
}
}, {
sort: {
_id: -1
},
limit: 10000,
fields: {
_id: false,
text: true
}
});
// 投稿が少なかったら中断
if (recentPosts.length < 100) {
process.stdout.write(' >>> -\n');
return resolve();
}
const domains = {};
// Extract domains from recent posts
recentPosts.forEach(post => {
const domainsOfPost = tokenize(post.text);
domainsOfPost.forEach(domain => {
if (domains[domain]) {
domains[domain]++;
} else {
domains[domain] = 1;
}
});
});
// Calc peak
let peak = 0;
Object.keys(domains).forEach(domain => {
if (domains[domain] > peak) peak = domains[domain];
});
// Sort domains by frequency
const domainsSorted = Object.keys(domains).sort((a, b) => domains[b] - domains[a]);
// Lookup top 10 domains
const topDomains = domainsSorted.slice(0, 10);
process.stdout.write(' >>> ' + topDomains.join(', ') + '\n');
// Make domains object (includes weights)
const domainsObj = topDomains.map(domain => ({
domain: domain,
weight: domains[domain] / peak
}));
// Save
User.update({ _id: id }, {
$set: {
domains: domainsObj
}
}).then(() => {
resolve();
}, err => {
reject(err);
});
});
}

View file

@ -0,0 +1,154 @@
const moji = require('moji');
const MeCab = require('./mecab');
import Post from '../../api/models/post';
import User from '../../api/models/user';
import parse from '../../api/common/text';
process.on('unhandledRejection', console.dir);
const stopwords = [
'ー',
'の', 'に', 'は', 'を', 'た', 'が', 'で', 'て', 'と', 'し', 'れ', 'さ',
'ある', 'いる', 'も', 'する', 'から', 'な', 'こと', 'として', 'い', 'や', 'れる',
'など', 'なっ', 'ない', 'この', 'ため', 'その', 'あっ', 'よう', 'また', 'もの',
'という', 'あり', 'まで', 'られ', 'なる', 'へ', 'か', 'だ', 'これ', 'によって',
'により', 'おり', 'より', 'による', 'ず', 'なり', 'られる', 'において', 'ば', 'なかっ',
'なく', 'しかし', 'について', 'せ', 'だっ', 'その後', 'できる', 'それ', 'う', 'ので',
'なお', 'のみ', 'でき', 'き', 'つ', 'における', 'および', 'いう', 'さらに', 'でも',
'ら', 'たり', 'その他', 'に関する', 'たち', 'ます', 'ん', 'なら', 'に対して', '特に',
'せる', '及び', 'これら', 'とき', 'では', 'にて', 'ほか', 'ながら', 'うち', 'そして',
'とともに', 'ただし', 'かつて', 'それぞれ', 'または', 'お', 'ほど', 'ものの', 'に対する',
'ほとんど', 'と共に', 'といった', 'です', 'とも', 'ところ', 'ここ', '感じ', '気持ち',
'あと', '自分', 'すき', '()',
'about', 'after', 'all', 'also', 'am', 'an', 'and', 'another', 'any', 'are', 'as', 'at', 'be',
'because', 'been', 'before', 'being', 'between', 'both', 'but', 'by', 'came', 'can',
'come', 'could', 'did', 'do', 'each', 'for', 'from', 'get', 'got', 'has', 'had',
'he', 'have', 'her', 'here', 'him', 'himself', 'his', 'how', 'if', 'in', 'into',
'is', 'it', 'like', 'make', 'many', 'me', 'might', 'more', 'most', 'much', 'must',
'my', 'never', 'now', 'of', 'on', 'only', 'or', 'other', 'our', 'out', 'over',
'said', 'same', 'see', 'should', 'since', 'some', 'still', 'such', 'take', 'than',
'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those',
'through', 'to', 'too', 'under', 'up', 'very', 'was', 'way', 'we', 'well', 'were',
'what', 'where', 'which', 'while', 'who', 'with', 'would', 'you', 'your', 'a', 'i'
];
const mecab = new MeCab();
function tokenize(text: string) {
if (text == null) return [];
// パース
const ast = parse(text);
const plain = ast
// テキストのみ(URLなどを除外するという意)
.filter(t => t.type == 'text' || t.type == 'bold')
.map(t => t.content)
.join('');
const tokens = mecab.parseSync(plain)
// キーワードのみ
.filter(token => token[1] == '名詞' && (token[2] == '固有名詞' || token[2] == '一般'))
// 取り出し(&整形(全角を半角にしたり大文字を小文字で統一したり))
.map(token => moji(token[0]).convert('ZE', 'HE').convert('HK', 'ZK').toString().toLowerCase())
// ストップワードなど
.filter(word =>
stopwords.indexOf(word) === -1 &&
word.length > 1 &&
word.indexOf('') === -1 &&
word.indexOf('!') === -1 &&
word.indexOf('') === -1 &&
word.indexOf('?') === -1);
return tokens;
}
// Fetch all users
User.find({}, {
fields: {
_id: true
}
}).then(users => {
let i = -1;
const x = cb => {
if (++i == users.length) return cb();
extractKeywordsOne(users[i]._id).then(() => x(cb), err => {
console.error(err);
setTimeout(() => {
i--;
x(cb);
}, 1000);
});
};
x(() => {
console.log('complete');
});
});
function extractKeywordsOne(id) {
return new Promise(async (resolve, reject) => {
process.stdout.write(`extracting keywords of ${id} ...`);
// Fetch recent posts
const recentPosts = await Post.find({
user_id: id,
text: {
$exists: true
}
}, {
sort: {
_id: -1
},
limit: 10000,
fields: {
_id: false,
text: true
}
});
// 投稿が少なかったら中断
if (recentPosts.length < 300) {
process.stdout.write(' >>> -\n');
return resolve();
}
const keywords = {};
// Extract keywords from recent posts
recentPosts.forEach(post => {
const keywordsOfPost = tokenize(post.text);
keywordsOfPost.forEach(keyword => {
if (keywords[keyword]) {
keywords[keyword]++;
} else {
keywords[keyword] = 1;
}
});
});
// Sort keywords by frequency
const keywordsSorted = Object.keys(keywords).sort((a, b) => keywords[b] - keywords[a]);
// Lookup top 10 keywords
const topKeywords = keywordsSorted.slice(0, 10);
process.stdout.write(' >>> ' + topKeywords.join(', ') + '\n');
// Save
User.update({ _id: id }, {
$set: {
keywords: topKeywords
}
}).then(() => {
resolve();
}, err => {
reject(err);
});
});
}

View file

@ -0,0 +1,85 @@
// Original source code: https://github.com/hecomi/node-mecab-async
// CUSTOMIZED BY SYUILO
var exec = require('child_process').exec;
var execSync = require('child_process').execSync;
var sq = require('shell-quote');
const config = require('../../conf').default;
// for backward compatibility
var MeCab = function() {};
MeCab.prototype = {
command : config.analysis.mecab_command ? config.analysis.mecab_command : 'mecab',
_format: function(arrayResult) {
var result = [];
if (!arrayResult) { return result; }
// Reference: http://mecab.googlecode.com/svn/trunk/mecab/doc/index.html
// 表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用形,活用型,原形,読み,発音
arrayResult.forEach(function(parsed) {
if (parsed.length <= 8) { return; }
result.push({
kanji : parsed[0],
lexical : parsed[1],
compound : parsed[2],
compound2 : parsed[3],
compound3 : parsed[4],
conjugation : parsed[5],
inflection : parsed[6],
original : parsed[7],
reading : parsed[8],
pronunciation : parsed[9] || ''
});
});
return result;
},
_shellCommand : function(str) {
return sq.quote(['echo', str]) + ' | ' + this.command;
},
_parseMeCabResult : function(result) {
return result.split('\n').map(function(line) {
return line.replace('\t', ',').split(',');
});
},
parse : function(str, callback) {
process.nextTick(function() { // for bug
exec(MeCab._shellCommand(str), function(err, result) {
if (err) { return callback(err); }
callback(err, MeCab._parseMeCabResult(result).slice(0,-2));
});
});
},
parseSync : function(str) {
var result = execSync(MeCab._shellCommand(str));
return MeCab._parseMeCabResult(String(result)).slice(0, -2);
},
parseFormat : function(str, callback) {
MeCab.parse(str, function(err, result) {
if (err) { return callback(err); }
callback(err, MeCab._format(result));
});
},
parseSyncFormat : function(str) {
return MeCab._format(MeCab.parseSync(str));
},
_wakatsu : function(arr) {
return arr.map(function(data) { return data[0]; });
},
wakachi : function(str, callback) {
MeCab.parse(str, function(err, arr) {
if (err) { return callback(err); }
callback(null, MeCab._wakatsu(arr));
});
},
wakachiSync : function(str) {
var arr = MeCab.parseSync(str);
return MeCab._wakatsu(arr);
}
};
for (var x in MeCab.prototype) {
MeCab[x] = MeCab.prototype[x];
}
module.exports = MeCab;

View file

@ -0,0 +1,302 @@
// Original source code: https://github.com/ttezel/bayes/blob/master/lib/naive_bayes.js (commit: 2c20d3066e4fc786400aaedcf3e42987e52abe3c)
// CUSTOMIZED BY SYUILO
/*
Expose our naive-bayes generator function
*/
module.exports = function (options) {
return new Naivebayes(options)
}
// keys we use to serialize a classifier's state
var STATE_KEYS = module.exports.STATE_KEYS = [
'categories', 'docCount', 'totalDocuments', 'vocabulary', 'vocabularySize',
'wordCount', 'wordFrequencyCount', 'options'
];
/**
* Initializes a NaiveBayes instance from a JSON state representation.
* Use this with classifier.toJson().
*
* @param {String} jsonStr state representation obtained by classifier.toJson()
* @return {NaiveBayes} Classifier
*/
module.exports.fromJson = function (jsonStr) {
var parsed;
try {
parsed = JSON.parse(jsonStr)
} catch (e) {
throw new Error('Naivebayes.fromJson expects a valid JSON string.')
}
// init a new classifier
var classifier = new Naivebayes(parsed.options)
// override the classifier's state
STATE_KEYS.forEach(function (k) {
if (!parsed[k]) {
throw new Error('Naivebayes.fromJson: JSON string is missing an expected property: `'+k+'`.')
}
classifier[k] = parsed[k]
})
return classifier
}
/**
* Given an input string, tokenize it into an array of word tokens.
* This is the default tokenization function used if user does not provide one in `options`.
*
* @param {String} text
* @return {Array}
*/
var defaultTokenizer = function (text) {
//remove punctuation from text - remove anything that isn't a word char or a space
var rgxPunctuation = /[^(a-zA-ZA-Яa-я0-9_)+\s]/g
var sanitized = text.replace(rgxPunctuation, ' ')
return sanitized.split(/\s+/)
}
/**
* Naive-Bayes Classifier
*
* This is a naive-bayes classifier that uses Laplace Smoothing.
*
* Takes an (optional) options object containing:
* - `tokenizer` => custom tokenization function
*
*/
function Naivebayes (options) {
// set options object
this.options = {}
if (typeof options !== 'undefined') {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw TypeError('NaiveBayes got invalid `options`: `' + options + '`. Pass in an object.')
}
this.options = options
}
this.tokenizer = this.options.tokenizer || defaultTokenizer
//initialize our vocabulary and its size
this.vocabulary = {}
this.vocabularySize = 0
//number of documents we have learned from
this.totalDocuments = 0
//document frequency table for each of our categories
//=> for each category, how often were documents mapped to it
this.docCount = {}
//for each category, how many words total were mapped to it
this.wordCount = {}
//word frequency table for each category
//=> for each category, how frequent was a given word mapped to it
this.wordFrequencyCount = {}
//hashmap of our category names
this.categories = {}
}
/**
* Initialize each of our data structure entries for this new category
*
* @param {String} categoryName
*/
Naivebayes.prototype.initializeCategory = function (categoryName) {
if (!this.categories[categoryName]) {
this.docCount[categoryName] = 0
this.wordCount[categoryName] = 0
this.wordFrequencyCount[categoryName] = {}
this.categories[categoryName] = true
}
return this
}
/**
* train our naive-bayes classifier by telling it what `category`
* the `text` corresponds to.
*
* @param {String} text
* @param {String} class
*/
Naivebayes.prototype.learn = function (text, category) {
var self = this
//initialize category data structures if we've never seen this category
self.initializeCategory(category)
//update our count of how many documents mapped to this category
self.docCount[category]++
//update the total number of documents we have learned from
self.totalDocuments++
//normalize the text into a word array
var tokens = self.tokenizer(text)
//get a frequency count for each token in the text
var frequencyTable = self.frequencyTable(tokens)
/*
Update our vocabulary and our word frequency count for this category
*/
Object
.keys(frequencyTable)
.forEach(function (token) {
//add this word to our vocabulary if not already existing
if (!self.vocabulary[token]) {
self.vocabulary[token] = true
self.vocabularySize++
}
var frequencyInText = frequencyTable[token]
//update the frequency information for this word in this category
if (!self.wordFrequencyCount[category][token])
self.wordFrequencyCount[category][token] = frequencyInText
else
self.wordFrequencyCount[category][token] += frequencyInText
//update the count of all words we have seen mapped to this category
self.wordCount[category] += frequencyInText
})
return self
}
/**
* Determine what category `text` belongs to.
*
* @param {String} text
* @return {String} category
*/
Naivebayes.prototype.categorize = function (text) {
var self = this
, maxProbability = -Infinity
, chosenCategory = null
var tokens = self.tokenizer(text)
var frequencyTable = self.frequencyTable(tokens)
//iterate thru our categories to find the one with max probability for this text
Object
.keys(self.categories)
.forEach(function (category) {
//start by calculating the overall probability of this category
//=> out of all documents we've ever looked at, how many were
// mapped to this category
var categoryProbability = self.docCount[category] / self.totalDocuments
//take the log to avoid underflow
var logProbability = Math.log(categoryProbability)
//now determine P( w | c ) for each word `w` in the text
Object
.keys(frequencyTable)
.forEach(function (token) {
var frequencyInText = frequencyTable[token]
var tokenProbability = self.tokenProbability(token, category)
// console.log('token: %s category: `%s` tokenProbability: %d', token, category, tokenProbability)
//determine the log of the P( w | c ) for this word
logProbability += frequencyInText * Math.log(tokenProbability)
})
if (logProbability > maxProbability) {
maxProbability = logProbability
chosenCategory = category
}
})
return chosenCategory
}
/**
* Calculate probability that a `token` belongs to a `category`
*
* @param {String} token
* @param {String} category
* @return {Number} probability
*/
Naivebayes.prototype.tokenProbability = function (token, category) {
//how many times this word has occurred in documents mapped to this category
var wordFrequencyCount = this.wordFrequencyCount[category][token] || 0
//what is the count of all words that have ever been mapped to this category
var wordCount = this.wordCount[category]
//use laplace Add-1 Smoothing equation
return ( wordFrequencyCount + 1 ) / ( wordCount + this.vocabularySize )
}
/**
* Build a frequency hashmap where
* - the keys are the entries in `tokens`
* - the values are the frequency of each entry in `tokens`
*
* @param {Array} tokens Normalized word array
* @return {Object}
*/
Naivebayes.prototype.frequencyTable = function (tokens) {
var frequencyTable = Object.create(null)
tokens.forEach(function (token) {
if (!frequencyTable[token])
frequencyTable[token] = 1
else
frequencyTable[token]++
})
return frequencyTable
}
/**
* Dump the classifier's state as a JSON string.
* @return {String} Representation of the classifier.
*/
Naivebayes.prototype.toJson = function () {
var state = {}
var self = this
STATE_KEYS.forEach(function (k) {
state[k] = self[k]
})
var jsonStr = JSON.stringify(state)
return jsonStr
}
// (original method)
Naivebayes.prototype.export = function () {
var state = {}
var self = this
STATE_KEYS.forEach(function (k) {
state[k] = self[k]
})
return state
}
module.exports.import = function (data) {
var parsed = data
// init a new classifier
var classifier = new Naivebayes()
// override the classifier's state
STATE_KEYS.forEach(function (k) {
if (!parsed[k]) {
throw new Error('Naivebayes.import: data is missing an expected property: `'+k+'`.')
}
classifier[k] = parsed[k]
})
return classifier
}

View file

@ -0,0 +1,35 @@
import Post from '../../api/models/post';
import Core from './core';
const c = new Core();
c.init().then(() => {
// 全ての(人間によって証明されていない)投稿を取得
Post.find({
text: {
$exists: true
},
is_category_verified: {
$ne: true
}
}, {
sort: {
_id: -1
},
fields: {
_id: true,
text: true
}
}).then(posts => {
posts.forEach(post => {
console.log(`predicting... ${post._id}`);
const category = c.predict(post.text);
Post.update({ _id: post._id }, {
$set: {
category: category
}
});
});
});
});

View file

@ -0,0 +1,45 @@
import Post from '../../api/models/post';
import User from '../../api/models/user';
export async function predictOne(id) {
console.log(`predict interest of ${id} ...`);
// TODO: repostなども含める
const recentPosts = await Post.find({
user_id: id,
category: {
$exists: true
}
}, {
sort: {
_id: -1
},
limit: 1000,
fields: {
_id: false,
category: true
}
});
const categories = {};
recentPosts.forEach(post => {
if (categories[post.category]) {
categories[post.category]++;
} else {
categories[post.category] = 1;
}
});
}
export async function predictAll() {
const allUsers = await User.find({}, {
fields: {
_id: true
}
});
allUsers.forEach(user => {
predictOne(user._id);
});
}

View file

@ -1,5 +1,6 @@
{
"compilerOptions": {
"allowJs": true,
"noEmitOnError": false,
"noImplicitAny": false,
"noImplicitReturns": true,

3
src/utils/type.ts Normal file
View file

@ -0,0 +1,3 @@
// https://github.com/Microsoft/TypeScript/issues/12215
export type Diff<T extends string, U extends string> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T];
export type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] };

View file

@ -5,8 +5,6 @@ json('../../const.json')
$theme-color = themeColor
$theme-color-foreground = themeColorForeground
@import './reset'
/*
::selection
background $theme-color
@ -14,6 +12,9 @@ $theme-color-foreground = themeColorForeground
*/
*
position relative
box-sizing border-box
background-clip padding-box !important
tap-highlight-color rgba($theme-color, 0.7)
-webkit-tap-highlight-color rgba($theme-color, 0.7)
@ -29,6 +30,9 @@ html
&, *
cursor progress !important
body
overflow-wrap break-word
#error
padding 32px
color #fff

View file

@ -1,4 +1,5 @@
@import "../base"
@import "../app"
@import "../reset"
html
background #eee

32
src/web/app/ch/router.js Normal file
View file

@ -0,0 +1,32 @@
import * as riot from 'riot';
const route = require('page');
let page = null;
export default me => {
route('/', index);
route('/:channel', channel);
route('*', notFound);
function index() {
mount(document.createElement('mk-index'));
}
function channel(ctx) {
const el = document.createElement('mk-channel');
el.setAttribute('id', ctx.params.channel);
mount(el);
}
function notFound() {
mount(document.createElement('mk-not-found'));
}
// EXEC
route();
};
function mount(content) {
if (page) page.unmount();
const body = document.getElementById('app');
page = riot.mount(body.appendChild(content))[0];
}

18
src/web/app/ch/script.js Normal file
View file

@ -0,0 +1,18 @@
/**
* Channels
*/
// Style
import './style.styl';
require('./tags');
import init from '../init';
import route from './router';
/**
* init
*/
init(me => {
// Start routing
route(me);
});

10
src/web/app/ch/style.styl Normal file
View file

@ -0,0 +1,10 @@
@import "../app"
html
padding 8px
background #efefef
#wait
top auto
bottom 15px
left 15px

View file

@ -0,0 +1,403 @@
<mk-channel>
<mk-header/>
<hr>
<main if={ !fetching }>
<h1>{ channel.title }</h1>
<div if={ SIGNIN }>
<p if={ channel.is_watching }>このチャンネルをウォッチしています <a onclick={ unwatch }>ウォッチ解除</a></p>
<p if={ !channel.is_watching }><a onclick={ watch }>このチャンネルをウォッチする</a></p>
</div>
<div class="share">
<mk-twitter-button/>
<mk-line-button/>
</div>
<div class="body">
<p if={ postsFetching }>読み込み中<mk-ellipsis/></p>
<div if={ !postsFetching }>
<p if={ posts == null || posts.length == 0 }>まだ投稿がありません</p>
<virtual if={ posts != null }>
<mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/>
</virtual>
</div>
</div>
<hr>
<mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/>
<div if={ !SIGNIN }>
<p>参加するには<a href={ CONFIG.url }>ログインまたは新規登録</a>してください</p>
</div>
<hr>
<footer>
<small><a href={ CONFIG.url }>Misskey</a> ver { version } (葵 aoi)</small>
</footer>
</main>
<style>
:scope
display block
> main
> h1
font-size 1.5em
color #f00
> .share
> *
margin-right 4px
> .body
margin 8px 0 0 0
> mk-channel-form
max-width 500px
</style>
<script>
import Progress from '../../common/scripts/loading';
import ChannelStream from '../../common/scripts/channel-stream';
this.mixin('i');
this.mixin('api');
this.id = this.opts.id;
this.fetching = true;
this.postsFetching = true;
this.channel = null;
this.posts = null;
this.connection = new ChannelStream(this.id);
this.version = VERSION;
this.unreadCount = 0;
this.on('mount', () => {
document.documentElement.style.background = '#efefef';
Progress.start();
let fetched = false;
// チャンネル概要読み込み
this.api('channels/show', {
channel_id: this.id
}).then(channel => {
if (fetched) {
Progress.done();
} else {
Progress.set(0.5);
fetched = true;
}
this.update({
fetching: false,
channel: channel
});
document.title = channel.title + ' | Misskey'
});
// 投稿読み込み
this.api('channels/posts', {
channel_id: this.id
}).then(posts => {
if (fetched) {
Progress.done();
} else {
Progress.set(0.5);
fetched = true;
}
this.update({
postsFetching: false,
posts: posts
});
});
this.connection.on('post', this.onPost);
document.addEventListener('visibilitychange', this.onVisibilitychange, false);
});
this.on('unmount', () => {
this.connection.off('post', this.onPost);
this.connection.close();
document.removeEventListener('visibilitychange', this.onVisibilitychange);
});
this.onPost = post => {
this.posts.unshift(post);
this.update();
if (document.hidden && this.SIGNIN && post.user_id !== this.I.id) {
this.unreadCount++;
document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`;
}
};
this.onVisibilitychange = () => {
if (!document.hidden) {
this.unreadCount = 0;
document.title = this.channel.title + ' | Misskey'
}
};
this.watch = () => {
this.api('channels/watch', {
channel_id: this.id
}).then(() => {
this.channel.is_watching = true;
this.update();
}, e => {
alert('error');
});
};
this.unwatch = () => {
this.api('channels/unwatch', {
channel_id: this.id
}).then(() => {
this.channel.is_watching = false;
this.update();
}, e => {
alert('error');
});
};
</script>
</mk-channel>
<mk-channel-post>
<header>
<a class="index" onclick={ reply }>{ post.index }:</a>
<a class="name" href={ CONFIG.url + '/' + post.user.username }><b>{ post.user.name }</b></a>
<mk-time time={ post.created_at }/>
<mk-time time={ post.created_at } mode="detail"/>
<span>ID:<i>{ post.user.username }</i></span>
</header>
<div>
<a if={ post.reply }>&gt;&gt;{ post.reply.index }</a>
{ post.text }
<div class="media" if={ post.media }>
<virtual each={ file in post.media }>
<a href={ file.url } target="_blank">
<img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/>
</a>
</virtual>
</div>
</div>
<style>
:scope
display block
margin 0
padding 0
> header
position -webkit-sticky
position sticky
z-index 1
top 0
background rgba(239, 239, 239, 0.9)
> .index
margin-right 0.25em
color #000
> .name
margin-right 0.5em
color #008000
> mk-time
margin-right 0.5em
&:first-of-type
display none
@media (max-width 600px)
> mk-time
&:first-of-type
display initial
&:last-of-type
display none
> div
padding 0 0 1em 2em
> .media
> a
display inline-block
> img
max-width 100%
vertical-align bottom
</style>
<script>
this.post = this.opts.post;
this.form = this.opts.form;
this.reply = () => {
this.form.update({
reply: this.post
});
};
</script>
</mk-channel-post>
<mk-channel-form>
<p if={ reply }><b>&gt;&gt;{ reply.index }</b> ({ reply.user.name }): <a onclick={ clearReply }>[x]</a></p>
<textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder="%i18n:ch.tags.mk-channel-form.textarea%"></textarea>
<div class="actions">
<button onclick={ selectFile }><i class="fa fa-upload"></i>%i18n:ch.tags.mk-channel-form.upload%</button>
<button onclick={ drive }><i class="fa fa-cloud"></i>%i18n:ch.tags.mk-channel-form.drive%</button>
<button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }>
<i class="fa fa-paper-plane" if={ !wait }></i>{ wait ? '%i18n:ch.tags.mk-channel-form.posting%' : '%i18n:ch.tags.mk-channel-form.post%' }<mk-ellipsis if={ wait }/>
</button>
</div>
<mk-uploader ref="uploader"/>
<ol if={ files }>
<li each={ files }>{ name }</li>
</ol>
<input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/>
<style>
:scope
display block
> textarea
width 100%
max-width 100%
min-width 100%
min-height 5em
> .actions
display flex
> button
> i
margin-right 0.25em
&:last-child
margin-left auto
&.wait
cursor wait
> input[type='file']
display none
</style>
<script>
import CONFIG from '../../common/scripts/config';
this.mixin('api');
this.channel = this.opts.channel;
this.files = null;
this.on('mount', () => {
this.refs.uploader.on('uploaded', file => {
this.update({
files: [file]
});
});
});
this.upload = file => {
this.refs.uploader.upload(file);
};
this.clearReply = () => {
this.update({
reply: null
});
};
this.clear = () => {
this.clearReply();
this.update({
files: null
});
this.refs.text.value = '';
};
this.post = () => {
this.update({
wait: true
});
const files = this.files && this.files.length > 0
? this.files.map(f => f.id)
: undefined;
this.api('posts/create', {
text: this.refs.text.value == '' ? undefined : this.refs.text.value,
media_ids: files,
reply_id: this.reply ? this.reply.id : undefined,
channel_id: this.channel.id
}).then(data => {
this.clear();
}).catch(err => {
alert('失敗した');
}).then(() => {
this.update({
wait: false
});
});
};
this.changeFile = () => {
this.refs.file.files.forEach(this.upload);
};
this.selectFile = () => {
this.refs.file.click();
};
this.drive = () => {
window['cb'] = files => {
this.update({
files: files
});
};
window.open(CONFIG.url + '/selectdrive?multiple=true',
'drive_window',
'height=500,width=800');
};
this.onkeydown = e => {
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post();
};
this.onpaste = e => {
e.clipboardData.items.forEach(item => {
if (item.kind == 'file') {
this.upload(item.getAsFile());
}
});
};
</script>
</mk-channel-form>
<mk-twitter-button>
<a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a>
<script>
this.on('mount', () => {
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://platform.twitter.com/widgets.js');
script.setAttribute('async', 'async');
head.appendChild(script);
});
</script>
</mk-twitter-button>
<mk-line-button>
<div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ CONFIG.chUrl } style="display: none;"></div>
<script>
this.on('mount', () => {
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://d.line-scdn.net/r/web/social-plugin/js/thirdparty/loader.min.js');
script.setAttribute('async', 'async');
head.appendChild(script);
});
</script>
</mk-line-button>

Some files were not shown because too many files have changed in this diff Show more