Merge branch 'master' into greenkeeper/@types/bcryptjs-2.4.1
This commit is contained in:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -3,6 +3,7 @@ | ||||
| /node_modules | ||||
| /built | ||||
| /uploads | ||||
| /data | ||||
| npm-debug.log | ||||
| *.pem | ||||
| run.bat | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -6,3 +6,5 @@ | ||||
| !/tools | ||||
| !/elasticsearch | ||||
| !/package.json | ||||
| !/.travis.yml | ||||
| !/appveyor.yml | ||||
|   | ||||
							
								
								
									
										169
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										169
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -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
									
								
							
							
						
						
									
										19
									
								
								DONORS.md
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										21
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								README.md
									
									
									
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										22
									
								
								docs/backup.md
									
									
									
									
									
										Normal 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/). | ||||
| @@ -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}* | ||||
|   | ||||
| @@ -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}* | ||||
|   | ||||
							
								
								
									
										151
									
								
								locales/en.yml
									
									
									
									
									
								
							
							
						
						
									
										151
									
								
								locales/en.yml
									
									
									
									
									
								
							| @@ -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" | ||||
|   | ||||
							
								
								
									
										152
									
								
								locales/ja.yml
									
									
									
									
									
								
							
							
						
						
									
										152
									
								
								locales/ja.yml
									
									
									
									
									
								
							| @@ -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: "知り合い" | ||||
|   | ||||
							
								
								
									
										114
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										114
									
								
								package.json
									
									
									
									
									
								
							| @@ -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", | ||||
| @@ -18,22 +18,22 @@ | ||||
|     "clean": "gulp clean", | ||||
|     "cleanall": "gulp cleanall", | ||||
|     "lint": "gulp lint", | ||||
|     "test": "gulp test" | ||||
| 		"test": "gulp test" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/bcryptjs": "2.4.1", | ||||
|     "@types/body-parser": "1.16.4", | ||||
|     "@types/chai": "4.0.3", | ||||
|     "@types/chai-http": "3.0.2", | ||||
|     "@types/chalk": "0.4.31", | ||||
|     "@types/compression": "0.0.33", | ||||
|     "@types/body-parser": "1.16.5", | ||||
|     "@types/chai": "4.0.4", | ||||
|     "@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.0", | ||||
|     "@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/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/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.2", | ||||
|     "@types/node": "8.0.24", | ||||
|     "@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.0", | ||||
|     "@types/riot": "3.6.0", | ||||
|     "@types/riot": "3.6.1", | ||||
|     "@types/serve-favicon": "2.2.28", | ||||
|     "@types/uuid": "3.4.0", | ||||
|     "@types/webpack": "3.0.9", | ||||
|     "@types/webpack-stream": "3.2.7", | ||||
|     "@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" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										398
									
								
								src/api/bot/core.ts
									
									
									
									
									
										Normal 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; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										234
									
								
								src/api/bot/interfaces/line.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								src/api/bot/interfaces/line.ts
									
									
									
									
									
										Normal 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); | ||||
| 		} | ||||
| 	}); | ||||
| }; | ||||
| @@ -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); | ||||
|   | ||||
							
								
								
									
										3
									
								
								src/api/common/generate-native-user-token.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/api/common/generate-native-user-token.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| import rndstr from 'rndstr'; | ||||
|  | ||||
| export default () => `!${rndstr('a-zA-Z0-9', 32)}`; | ||||
							
								
								
									
										52
									
								
								src/api/common/read-notification.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/api/common/read-notification.ts
									
									
									
									
									
										Normal 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'); | ||||
| 	} | ||||
| }); | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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' | ||||
| 							} | ||||
|   | ||||
| @@ -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 | ||||
| 			}}, | ||||
|   | ||||
| @@ -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' | ||||
| 							} | ||||
|   | ||||
| @@ -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' | ||||
| 							} | ||||
|   | ||||
							
								
								
									
										59
									
								
								src/api/endpoints/channels.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/api/endpoints/channels.ts
									
									
									
									
									
										Normal 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)))); | ||||
| }); | ||||
							
								
								
									
										39
									
								
								src/api/endpoints/channels/create.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/api/endpoints/channels/create.ts
									
									
									
									
									
										Normal 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 | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										79
									
								
								src/api/endpoints/channels/posts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/api/endpoints/channels/posts.ts
									
									
									
									
									
										Normal 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) | ||||
| 	))); | ||||
| }); | ||||
							
								
								
									
										31
									
								
								src/api/endpoints/channels/show.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/api/endpoints/channels/show.ts
									
									
									
									
									
										Normal 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)); | ||||
| }); | ||||
							
								
								
									
										60
									
								
								src/api/endpoints/channels/unwatch.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/api/endpoints/channels/unwatch.ts
									
									
									
									
									
										Normal 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 | ||||
| 		} | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										58
									
								
								src/api/endpoints/channels/watch.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/api/endpoints/channels/watch.ts
									
									
									
									
									
										Normal 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 | ||||
| 		} | ||||
| 	}); | ||||
| }); | ||||
| @@ -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] || { | ||||
|   | ||||
| @@ -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; | ||||
| }; | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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; | ||||
| }; | ||||
|   | ||||
| @@ -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 | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
|   | ||||
| @@ -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)))); | ||||
| }); | ||||
|   | ||||
| @@ -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'; | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -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'); | ||||
|   | ||||
							
								
								
									
										42
									
								
								src/api/endpoints/i/change_password.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/api/endpoints/i/change_password.ts
									
									
									
									
									
										Normal 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(); | ||||
| }); | ||||
| @@ -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); | ||||
| 	} | ||||
| }); | ||||
|   | ||||
							
								
								
									
										44
									
								
								src/api/endpoints/i/pin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/api/endpoints/i/pin.ts
									
									
									
									
									
										Normal 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); | ||||
| }); | ||||
							
								
								
									
										42
									
								
								src/api/endpoints/i/regenerate_token.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/api/endpoints/i/regenerate_token.ts
									
									
									
									
									
										Normal 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'); | ||||
| }); | ||||
| @@ -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) { | ||||
|   | ||||
							
								
								
									
										23
									
								
								src/api/endpoints/notifications/get_unread_count.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/api/endpoints/notifications/get_unread_count.ts
									
									
									
									
									
										Normal 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 | ||||
| 	}); | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
							
								
								
									
										32
									
								
								src/api/endpoints/notifications/mark_as_read_all.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/api/endpoints/notifications/mark_as_read_all.ts
									
									
									
									
									
										Normal 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'); | ||||
| }); | ||||
| @@ -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) { | ||||
|   | ||||
							
								
								
									
										52
									
								
								src/api/endpoints/posts/categorize.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/api/endpoints/posts/categorize.ts
									
									
									
									
									
										Normal 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(); | ||||
| }); | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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,15 +191,15 @@ 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, | ||||
| 				repost: repost ? repost._id.toString() : null, | ||||
| 				media_ids: (files || []).map(file => file._id.toString()) | ||||
| 			})) { | ||||
| 			text: text, | ||||
| 			reply: reply ? reply._id.toString() : null, | ||||
| 			repost: repost ? repost._id.toString() : null, | ||||
| 			media_ids: (files || []).map(file => file._id.toString()) | ||||
| 		})) { | ||||
| 			return rej('duplicate'); | ||||
| 		} | ||||
| 	} | ||||
| @@ -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,23 +247,51 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Publish event to myself's stream | ||||
| 	event(user._id, 'post', postObj); | ||||
| 	// タイムラインへの投稿 | ||||
| 	if (!channel) { | ||||
| 		// Publish event to myself's stream | ||||
| 		event(user._id, 'post', postObj); | ||||
|  | ||||
| 	// Fetch all followers | ||||
| 	const followers = await Following | ||||
| 		.find({ | ||||
| 			followee_id: user._id, | ||||
| 			// 削除されたドキュメントは除く | ||||
| 			deleted_at: { $exists: false } | ||||
| 		}, { | ||||
| 			follower_id: true, | ||||
| 			_id: false | ||||
| 		// Fetch all followers | ||||
| 		const followers = await Following | ||||
| 			.find({ | ||||
| 				followee_id: user._id, | ||||
| 				// 削除されたドキュメントは除く | ||||
| 				deleted_at: { $exists: false } | ||||
| 			}, { | ||||
| 				follower_id: true, | ||||
| 				_id: false | ||||
| 			}); | ||||
|  | ||||
| 		// 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 followers stream | ||||
| 	followers.forEach(following => | ||||
| 		event(following.follower_id, 'post', postObj)); | ||||
| 		// 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 | ||||
| }); | ||||
|   | ||||
| @@ -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: { | ||||
|   | ||||
| @@ -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 = { | ||||
| 		user_id: { | ||||
| 			$in: followingIds | ||||
| 		} | ||||
| 		$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; | ||||
| }; | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
							
								
								
									
										96
									
								
								src/api/endpoints/users/get_frequently_replied_users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/api/endpoints/users/get_frequently_replied_users.ts
									
									
									
									
									
										Normal 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); | ||||
| }); | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
							
								
								
									
										3
									
								
								src/api/models/channel-watching.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/api/models/channel-watching.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										14
									
								
								src/api/models/channel.ts
									
									
									
									
									
										Normal 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; | ||||
| }; | ||||
| @@ -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) && | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
| }; | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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: { | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
							
								
								
									
										66
									
								
								src/api/serializers/channel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/api/serializers/channel.ts
									
									
									
									
									
										Normal 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); | ||||
| }); | ||||
| @@ -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); | ||||
| }); | ||||
|   | ||||
| @@ -44,7 +44,7 @@ const self = ( | ||||
| 		}); | ||||
|  | ||||
| 		const childFilesCount = await DriveFile.count({ | ||||
| 			folder_id: _folder.id | ||||
| 			'metadata.folder_id': _folder.id | ||||
| 		}); | ||||
|  | ||||
| 		_folder.folders_count = childFoldersCount; | ||||
|   | ||||
| @@ -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); | ||||
| 	} | ||||
|  | ||||
| 	// Populate media | ||||
| 	if (_post.media_ids) { | ||||
| 		// Populate media | ||||
| 		_post.media = await Promise.all(_post.media_ids.map(async fileId => | ||||
| 			await serializeDriveFile(fileId) | ||||
| 		_post.media = Promise.all(_post.media_ids.map(fileId => | ||||
| 			serializeDriveFile(fileId) | ||||
| 		)); | ||||
| 	} | ||||
|  | ||||
| 	if (_post.reply_to_id && opts.detail) { | ||||
| 		// Populate reply to post | ||||
| 		_post.reply_to = await self(_post.reply_to_id, me, { | ||||
| 			detail: false | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (_post.repost_id && opts.detail) { | ||||
| 		// Populate repost | ||||
| 		_post.repost = await self(_post.repost_id, me, { | ||||
| 			detail: _post.text == null | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	// Poll | ||||
| 	if (me && _post.poll && opts.detail) { | ||||
| 		const vote = await Vote | ||||
| 			.findOne({ | ||||
| 				user_id: me._id, | ||||
| 				post_id: id | ||||
| 	// 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; | ||||
| 		})(); | ||||
|  | ||||
| 		if (vote != null) { | ||||
| 			_post.poll.choices.filter(c => c.id == vote.choice)[0].is_voted = true; | ||||
| 		// 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 = self(_post.reply_id, meId, { | ||||
| 				detail: false | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (_post.repost_id) { | ||||
| 			// Populate repost | ||||
| 			_post.repost = self(_post.repost_id, meId, { | ||||
| 				detail: _post.text == null | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// Poll | ||||
| 		if (meId && _post.poll) { | ||||
| 			_post.poll = (async (poll) => { | ||||
| 				const vote = await Vote | ||||
| 					.findOne({ | ||||
| 						user_id: meId, | ||||
| 						post_id: id | ||||
| 					}); | ||||
|  | ||||
| 				if (vote != null) { | ||||
| 					const myChoice = poll.choices | ||||
| 						.filter(c => c.id == vote.choice)[0]; | ||||
|  | ||||
| 					myChoice.is_voted = true; | ||||
| 				} | ||||
|  | ||||
| 				return poll; | ||||
| 			})(_post.poll); | ||||
| 		} | ||||
|  | ||||
| 		// Fetch my reaction | ||||
| 		if (meId) { | ||||
| 			_post.my_reaction = (async () => { | ||||
| 				const reaction = await Reaction | ||||
| 					.findOne({ | ||||
| 						user_id: meId, | ||||
| 						post_id: id, | ||||
| 						deleted_at: { $exists: false } | ||||
| 					}); | ||||
|  | ||||
| 				if (reaction) { | ||||
| 					return reaction.reaction; | ||||
| 				} | ||||
|  | ||||
| 				return null; | ||||
| 			})(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Fetch my reaction | ||||
| 	if (me && opts.detail) { | ||||
| 		const reaction = await Reaction | ||||
| 			.findOne({ | ||||
| 				user_id: me._id, | ||||
| 				post_id: id, | ||||
| 				deleted_at: { $exists: false } | ||||
| 			}); | ||||
| 	// resolve promises in _post object | ||||
| 	_post = await rap(_post); | ||||
|  | ||||
| 		if (reaction) { | ||||
| 			_post.my_reaction = reaction.reaction; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	resolve(_post); | ||||
| }); | ||||
| 	return _post; | ||||
| }; | ||||
|  | ||||
| export default self; | ||||
|   | ||||
| @@ -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,51 +98,65 @@ 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 | ||||
| 		const follow = await Following.findOne({ | ||||
| 			follower_id: me, | ||||
| 			followee_id: _user.id, | ||||
| 			deleted_at: { $exists: false } | ||||
| 		}); | ||||
| 		_user.is_following = follow !== null; | ||||
| 		_user.is_following = (async () => { | ||||
| 			const follow = await Following.findOne({ | ||||
| 				follower_id: meId, | ||||
| 				followee_id: _user.id, | ||||
| 				deleted_at: { $exists: false } | ||||
| 			}); | ||||
| 			return follow !== null; | ||||
| 		})(); | ||||
|  | ||||
| 		// If the user is followed | ||||
| 		const follow2 = await Following.findOne({ | ||||
| 			follower_id: _user.id, | ||||
| 			followee_id: me, | ||||
| 			deleted_at: { $exists: false } | ||||
| 		}); | ||||
| 		_user.is_followed = follow2 !== null; | ||||
| 		_user.is_followed = (async () => { | ||||
| 			const follow2 = await Following.findOne({ | ||||
| 				follower_id: _user.id, | ||||
| 				followee_id: meId, | ||||
| 				deleted_at: { $exists: false } | ||||
| 			}); | ||||
| 			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 | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// Get following you know count | ||||
| 		const followingYouKnowCount = await Following.count({ | ||||
| 			followee_id: { $in: myFollowingIds }, | ||||
| 			follower_id: _user.id, | ||||
| 			deleted_at: { $exists: false } | ||||
| 		}); | ||||
| 		_user.following_you_know_count = followingYouKnowCount; | ||||
| 		if (meId && !meId.equals(_user.id)) { | ||||
| 			const myFollowingIds = await getFriends(meId); | ||||
|  | ||||
| 		// Get followers you know count | ||||
| 		const followersYouKnowCount = await Following.count({ | ||||
| 			followee_id: _user.id, | ||||
| 			follower_id: { $in: myFollowingIds }, | ||||
| 			deleted_at: { $exists: false } | ||||
| 		}); | ||||
| 		_user.followers_you_know_count = followersYouKnowCount; | ||||
| 			// Get following you know count | ||||
| 			_user.following_you_know_count = Following.count({ | ||||
| 				followee_id: { $in: myFollowingIds }, | ||||
| 				follower_id: _user.id, | ||||
| 				deleted_at: { $exists: false } | ||||
| 			}); | ||||
|  | ||||
| 			// Get followers you know count | ||||
| 			_user.followers_you_know_count = Following.count({ | ||||
| 				followee_id: _user.id, | ||||
| 				follower_id: { $in: myFollowingIds }, | ||||
| 				deleted_at: { $exists: false } | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// resolve promises in _user object | ||||
| 	_user = await rap(_user); | ||||
|  | ||||
| 	resolve(_user); | ||||
| }); | ||||
| /* | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										12
									
								
								src/api/stream/channel.ts
									
									
									
									
									
										Normal 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); | ||||
| 	}); | ||||
| } | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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); | ||||
| 	}); | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
| 		} | ||||
|   | ||||
| @@ -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: ...'; | ||||
| 		} | ||||
							
								
								
									
										12
									
								
								src/common/get-user-summary.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/common/get-user-summary.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										268
									
								
								src/common/othello.ts
									
									
									
									
									
										Normal 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 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; | ||||
| 		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(); | ||||
| } | ||||
| @@ -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}`; | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| { | ||||
| 	"themeColor": "#87bb35", | ||||
| 	"themeColorForeground": "#fff", | ||||
| 	"idea": ["#f13049", "#f43636"] | ||||
| 	"themeColor": "#f43636", | ||||
| 	"themeColorForeground": "#fff" | ||||
| } | ||||
|   | ||||
| @@ -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 }; | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
							
								
								
									
										49
									
								
								src/tools/analysis/core.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/tools/analysis/core.ts
									
									
									
									
									
										Normal 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); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										120
									
								
								src/tools/analysis/extract-user-domains.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/tools/analysis/extract-user-domains.ts
									
									
									
									
									
										Normal 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); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										154
									
								
								src/tools/analysis/extract-user-keywords.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								src/tools/analysis/extract-user-keywords.ts
									
									
									
									
									
										Normal 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); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										85
									
								
								src/tools/analysis/mecab.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/tools/analysis/mecab.js
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										302
									
								
								src/tools/analysis/naive-bayes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								src/tools/analysis/naive-bayes.js
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/tools/analysis/predict-all-post-category.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/tools/analysis/predict-all-post-category.ts
									
									
									
									
									
										Normal 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 | ||||
| 				} | ||||
| 			}); | ||||
| 		}); | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										45
									
								
								src/tools/analysis/predict-user-interst.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/tools/analysis/predict-user-interst.ts
									
									
									
									
									
										Normal 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); | ||||
| 	}); | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "allowJs": true, | ||||
|     "noEmitOnError": false, | ||||
|     "noImplicitAny": false, | ||||
|     "noImplicitReturns": true, | ||||
|   | ||||
							
								
								
									
										3
									
								
								src/utils/type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/utils/type.ts
									
									
									
									
									
										Normal 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] }; | ||||
| @@ -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 | ||||
| @@ -1,4 +1,5 @@ | ||||
| @import "../base" | ||||
| @import "../app" | ||||
| @import "../reset" | ||||
|  | ||||
| html | ||||
| 	background #eee | ||||
|   | ||||
							
								
								
									
										32
									
								
								src/web/app/ch/router.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/web/app/ch/router.js
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										18
									
								
								src/web/app/ch/script.js
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										10
									
								
								src/web/app/ch/style.styl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| @import "../app" | ||||
|  | ||||
| html | ||||
| 	padding 8px | ||||
| 	background #efefef | ||||
|  | ||||
| #wait | ||||
| 	top auto | ||||
| 	bottom 15px | ||||
| 	left 15px | ||||
							
								
								
									
										403
									
								
								src/web/app/ch/tags/channel.tag
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										403
									
								
								src/web/app/ch/tags/channel.tag
									
									
									
									
									
										Normal 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 }>>>{ 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>>>{ 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
		Reference in New Issue
	
	Block a user
	 こぴなたみぽ
					こぴなたみぽ