From cb6f390fb6964a032f15c6885d686d07c945ad38 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 7 Nov 2018 13:14:52 +0900
Subject: [PATCH] =?UTF-8?q?GitHub=20/=20Twitter=E9=80=A3=E6=90=BA=E3=81=AE?=
 =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=82=92DB=E3=81=AB=E4=BF=9D=E5=AD=98?=
 =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 docs/setup.en.md                              |   7 -
 locales/ja-JP.yml                             |  10 +
 src/client/app/admin/views/instance.vue       |  40 ++
 src/config/types.ts                           |   9 +-
 src/misc/fetch-meta.ts                        |   4 +-
 src/models/meta.ts                            |  34 ++
 src/server/api/endpoints/admin/update-meta.ts |  68 ++-
 src/server/api/endpoints/meta.ts              |   4 +-
 src/server/api/index.ts                       |   1 +
 src/server/api/service/github-bot.ts          | 156 ++++++
 src/server/api/service/github.ts              | 526 +++++++-----------
 src/server/api/service/twitter.ts             | 249 +++++----
 12 files changed, 632 insertions(+), 476 deletions(-)
 create mode 100644 src/server/api/service/github-bot.ts

diff --git a/docs/setup.en.md b/docs/setup.en.md
index 83392d0d9..3a1c7bff9 100644
--- a/docs/setup.en.md
+++ b/docs/setup.en.md
@@ -57,13 +57,6 @@ npm install web-push -g
 web-push generate-vapid-keys
 ```
 
-*(optional)* Create a twitter application
-----------------------------------------------------------------
-If you want to enable the twitter integration, you need to create a twitter app at [https://developer.twitter.com/en/apply/user](https://developer.twitter.com/en/apply/user).
-
-In the app you need to set the oauth callback url as : https://misskey-instance/api/tw/cb
-
-
 *5.* Make configuration file
 ----------------------------------------------------------------
 1. `cp .config/example.yml .config/default.yml` Copy the `.config/example.yml` and rename it to `default.yml`.
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index c3a9848b7..572c8ccdf 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1095,6 +1095,16 @@ admin/views/instance.vue:
   enable-recaptcha: "reCAPTCHAを有効にする"
   recaptcha-site-key: "reCAPTCHA site key"
   recaptcha-secret-key: "reCAPTCHA secret key"
+  twitter-integration-config: "Twitter連携の設定"
+  twitter-integration-info: "コールバックURLは /api/tw/cb に設定します。"
+  enable-twitter-integration: "Twitter連携を有効にする"
+  twitter-integration-consumer-key: "Consumer key"
+  twitter-integration-consumer-secret: "Consumer secret"
+  github-integration-config: "GitHub連携の設定"
+  github-integration-info: "コールバックURLは /api/gh/cb に設定します。"
+  enable-github-integration: "GitHub連携を有効にする"
+  github-integration-client-id: "Client ID"
+  github-integration-client-secret: "Client secret"
   proxy-account-config: "プロキシアカウントの設定"
   proxy-account-info: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。"
   proxy-account-username: "プロキシアカウントのユーザー名"
diff --git a/src/client/app/admin/views/instance.vue b/src/client/app/admin/views/instance.vue
index 815cea631..a463bdc73 100644
--- a/src/client/app/admin/views/instance.vue
+++ b/src/client/app/admin/views/instance.vue
@@ -53,6 +53,28 @@
 			<p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p>
 		</section>
 	</ui-card>
+
+	<ui-card>
+		<div slot="title"><fa :icon="['fab', 'twitter']"/> %i18n:@twitter-integration-config%</div>
+		<section>
+			<ui-switch v-model="enableTwitterIntegration">%i18n:@enable-twitter-integration%</ui-switch>
+			<ui-info>%i18n:@twitter-integration-info%</ui-info>
+			<ui-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><i slot="icon"><fa icon="key"/></i>%i18n:@twitter-integration-consumer-key%</ui-input>
+			<ui-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><i slot="icon"><fa icon="key"/></i>%i18n:@twitter-integration-consumer-secret%</ui-input>
+			<ui-button @click="updateMeta">%i18n:@save%</ui-button>
+		</section>
+	</ui-card>
+
+	<ui-card>
+		<div slot="title"><fa :icon="['fab', 'github']"/> %i18n:@github-integration-config%</div>
+		<section>
+			<ui-switch v-model="enableGithubIntegration">%i18n:@enable-github-integration%</ui-switch>
+			<ui-info>%i18n:@github-integration-info%</ui-info>
+			<ui-input v-model="githubClientId" :disabled="!enableGithubIntegration"><i slot="icon"><fa icon="key"/></i>%i18n:@github-integration-client-id%</ui-input>
+			<ui-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><i slot="icon"><fa icon="key"/></i>%i18n:@github-integration-client-secret%</ui-input>
+			<ui-button @click="updateMeta">%i18n:@save%</ui-button>
+		</section>
+	</ui-card>
 </div>
 </template>
 
@@ -77,6 +99,12 @@ export default Vue.extend({
 			enableRecaptcha: false,
 			recaptchaSiteKey: null,
 			recaptchaSecretKey: null,
+			enableTwitterIntegration: false,
+			twitterConsumerKey: null,
+			twitterConsumerSecret: null,
+			enableGithubIntegration: false,
+			githubClientId: null,
+			githubClientSecret: null,
 			proxyAccount: null,
 			inviteCode: null,
 		};
@@ -98,6 +126,12 @@ export default Vue.extend({
 			this.recaptchaSiteKey = meta.recaptchaSiteKey;
 			this.recaptchaSecretKey = meta.recaptchaSecretKey;
 			this.proxyAccount = meta.proxyAccount;
+			this.enableTwitterIntegration = meta.enableTwitterIntegration;
+			this.twitterConsumerKey = meta.twitterConsumerKey;
+			this.twitterConsumerSecret = meta.twitterConsumerSecret;
+			this.enableGithubIntegration = meta.enableGithubIntegration;
+			this.githubClientId = meta.githubClientId;
+			this.githubClientSecret = meta.githubClientSecret;
 		});
 	},
 
@@ -131,6 +165,12 @@ export default Vue.extend({
 				recaptchaSiteKey: this.recaptchaSiteKey,
 				recaptchaSecretKey: this.recaptchaSecretKey,
 				proxyAccount: this.proxyAccount,
+				enableTwitterIntegration: this.enableTwitterIntegration,
+				twitterConsumerKey: this.twitterConsumerKey,
+				twitterConsumerSecret: this.twitterConsumerSecret,
+				enableGithubIntegration: this.enableGithubIntegration,
+				githubClientId: this.githubClientId,
+				githubClientSecret: this.githubClientSecret,
 			}).then(() => {
 				this.$swal({
 					type: 'success',
diff --git a/src/config/types.ts b/src/config/types.ts
index 98fa2660f..f9cb9d865 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -40,14 +40,7 @@ export type Source = {
 	summalyProxy?: string;
 
 	accesslog?: string;
-	twitter?: {
-		consumer_key: string;
-		consumer_secret: string;
-	};
-	github?: {
-		client_id: string;
-		client_secret: string;
-	};
+
 	github_bot?: {
 		hook_secret: string;
 		username: string;
diff --git a/src/misc/fetch-meta.ts b/src/misc/fetch-meta.ts
index e8ff1aca7..622ad5439 100644
--- a/src/misc/fetch-meta.ts
+++ b/src/misc/fetch-meta.ts
@@ -11,7 +11,9 @@ const defaultMeta: any = {
 		originalNotesCount: 0,
 		originalUsersCount: 0
 	},
-	maxNoteTextLength: 1000
+	maxNoteTextLength: 1000,
+	enableTwitterIntegration: false,
+	enableGithubIntegration: false,
 };
 
 export default async function(): Promise<IMeta> {
diff --git a/src/models/meta.ts b/src/models/meta.ts
index f62117a0b..a12747ea3 100644
--- a/src/models/meta.ts
+++ b/src/models/meta.ts
@@ -99,6 +99,32 @@ if ((config as any).maintainer) {
 		}
 	});
 }
+if ((config as any).twitter) {
+	Meta.findOne({}).then(m => {
+		if (m != null && m.enableTwitterIntegration == null) {
+			Meta.update({}, {
+				$set: {
+					enableTwitterIntegration: true,
+					twitterConsumerKey: (config as any).twitter.consumer_key,
+					twitterConsumerSecret: (config as any).twitter.consumer_secret
+				}
+			});
+		}
+	});
+}
+if ((config as any).github) {
+	Meta.findOne({}).then(m => {
+		if (m != null && m.enableGithubIntegration == null) {
+			Meta.update({}, {
+				$set: {
+					enableGithubIntegration: true,
+					githubClientId: (config as any).github.client_id,
+					githubClientSecret: (config as any).github.client_secret
+				}
+			});
+		}
+	});
+}
 
 export type IMeta = {
 	name?: string;
@@ -157,4 +183,12 @@ export type IMeta = {
 	 * Max allowed note text length in charactors
 	 */
 	maxNoteTextLength?: number;
+
+	enableTwitterIntegration?: boolean;
+	twitterConsumerKey?: string;
+	twitterConsumerSecret?: string;
+
+	enableGithubIntegration?: boolean;
+	githubClientId?: string;
+	githubClientSecret?: string;
 };
diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts
index d45a8759f..39d7ef86b 100644
--- a/src/server/api/endpoints/admin/update-meta.ts
+++ b/src/server/api/endpoints/admin/update-meta.ts
@@ -137,7 +137,49 @@ export const meta = {
 			desc: {
 				'ja-JP': 'インスタンスの対象言語'
 			}
-		}
+		},
+
+		enableTwitterIntegration: {
+			validator: $.bool.optional,
+			desc: {
+				'ja-JP': 'Twitter連携機能を有効にするか否か'
+			}
+		},
+
+		twitterConsumerKey: {
+			validator: $.str.optional.nullable,
+			desc: {
+				'ja-JP': 'TwitterアプリのConsumer key'
+			}
+		},
+
+		twitterConsumerSecret: {
+			validator: $.str.optional.nullable,
+			desc: {
+				'ja-JP': 'TwitterアプリのConsumer secret'
+			}
+		},
+
+		enableGithubIntegration: {
+			validator: $.bool.optional,
+			desc: {
+				'ja-JP': 'GitHub連携機能を有効にするか否か'
+			}
+		},
+
+		githubClientId: {
+			validator: $.str.optional.nullable,
+			desc: {
+				'ja-JP': 'GitHubアプリのClient ID'
+			}
+		},
+
+		githubClientSecret: {
+			validator: $.str.optional.nullable,
+			desc: {
+				'ja-JP': 'GitHubアプリのClient secret'
+			}
+		},
 	}
 };
 
@@ -216,6 +258,30 @@ export default define(meta, (ps) => new Promise(async (res, rej) => {
 		set.langs = ps.langs;
 	}
 
+	if (ps.enableTwitterIntegration !== undefined) {
+		set.enableTwitterIntegration = ps.enableTwitterIntegration;
+	}
+
+	if (ps.twitterConsumerKey !== undefined) {
+		set.twitterConsumerKey = ps.twitterConsumerKey;
+	}
+
+	if (ps.twitterConsumerSecret !== undefined) {
+		set.twitterConsumerSecret = ps.twitterConsumerSecret;
+	}
+
+	if (ps.enableGithubIntegration !== undefined) {
+		set.enableGithubIntegration = ps.enableGithubIntegration;
+	}
+
+	if (ps.githubClientId !== undefined) {
+		set.githubClientId = ps.githubClientId;
+	}
+
+	if (ps.githubClientSecret !== undefined) {
+		set.githubClientSecret = ps.githubClientSecret;
+	}
+
 	await Meta.update({}, {
 		$set: set
 	}, { upsert: true });
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 625a9519d..7cd72d3cc 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -77,8 +77,8 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => {
 			elasticsearch: config.elasticsearch ? true : false,
 			recaptcha: instance.enableRecaptcha,
 			objectStorage: config.drive && config.drive.storage === 'minio',
-			twitter: config.twitter ? true : false,
-			github: config.github ? true : false,
+			twitter: instance.enableTwitterIntegration,
+			github: instance.enableGithubIntegration,
 			serviceWorker: config.sw ? true : false,
 			userRecommendation: config.user_recommendation ? config.user_recommendation : {}
 		};
diff --git a/src/server/api/index.ts b/src/server/api/index.ts
index 33e98f650..bb8bad8bb 100644
--- a/src/server/api/index.ts
+++ b/src/server/api/index.ts
@@ -44,6 +44,7 @@ router.post('/signup', require('./private/signup').default);
 router.post('/signin', require('./private/signin').default);
 
 router.use(require('./service/github').routes());
+router.use(require('./service/github-bot').routes());
 router.use(require('./service/twitter').routes());
 
 router.use(require('./mastodon').routes());
diff --git a/src/server/api/service/github-bot.ts b/src/server/api/service/github-bot.ts
new file mode 100644
index 000000000..cb038363f
--- /dev/null
+++ b/src/server/api/service/github-bot.ts
@@ -0,0 +1,156 @@
+import * as EventEmitter from 'events';
+import * as Router from 'koa-router';
+import * as request from 'request';
+import User, { IUser } from '../../../models/user';
+import createNote from '../../../services/note/create';
+import config from '../../../config';
+const crypto = require('crypto');
+
+const handler = new EventEmitter();
+
+let bot: IUser;
+
+const post = async (text: string, home = true) => {
+	if (bot == null) {
+		const account = await User.findOne({
+			usernameLower: config.github_bot.username.toLowerCase()
+		});
+
+		if (account == null) {
+			console.warn(`GitHub hook bot specified, but not found: @${config.github_bot.username}`);
+			return;
+		} else {
+			bot = account;
+		}
+	}
+
+	createNote(bot, { text, visibility: home ? 'home' : 'public' });
+};
+
+// Init router
+const router = new Router();
+
+if (config.github_bot) {
+	const secret = config.github_bot.hook_secret;
+
+	router.post('/hooks/github', ctx => {
+		const body = JSON.stringify(ctx.request.body);
+		const hash = crypto.createHmac('sha1', secret).update(body).digest('hex');
+		const sig1 = new Buffer(ctx.headers['x-hub-signature']);
+		const sig2 = new Buffer(`sha1=${hash}`);
+
+		// シグネチャ比較
+		if (sig1.equals(sig2)) {
+			handler.emit(ctx.headers['x-github-event'], ctx.request.body);
+			ctx.status = 204;
+		} else {
+			ctx.status = 400;
+		}
+	});
+}
+
+module.exports = router;
+
+handler.on('status', event => {
+	const state = event.state;
+	switch (state) {
+		case 'error':
+		case 'failure':
+			const commit = event.commit;
+			const parent = commit.parents[0];
+
+			// Fetch parent status
+			request({
+				url: `${parent.url}/statuses`,
+				proxy: config.proxy,
+				headers: {
+					'User-Agent': 'misskey'
+				}
+			}, (err, res, body) => {
+				if (err) {
+					console.error(err);
+					return;
+				}
+				const parentStatuses = JSON.parse(body);
+				const parentState = parentStatuses[0].state;
+				const stillFailed = parentState == 'failure' || parentState == 'error';
+				if (stillFailed) {
+					post(`**⚠️BUILD STILL FAILED⚠️**: ?[${commit.commit.message}](${commit.html_url})`);
+				} else {
+					post(`**🚨BUILD FAILED🚨**: →→→?[${commit.commit.message}](${commit.html_url})←←←`);
+				}
+			});
+			break;
+	}
+});
+
+handler.on('push', event => {
+	const ref = event.ref;
+	switch (ref) {
+		case 'refs/heads/master':
+			const pusher = event.pusher;
+			const compare = event.compare;
+			const commits: any[] = event.commits;
+			post([
+				`Pushed by **${pusher.name}** with ?[${commits.length} commit${commits.length > 1 ? 's' : ''}](${compare}):`,
+				commits.reverse().map(commit => `・[?[${commit.id.substr(0, 7)}](${commit.url})] ${commit.message.split('\n')[0]}`).join('\n'),
+			].join('\n'));
+			break;
+		case 'refs/heads/release':
+			const commit = event.commits[0];
+			post(`RELEASED: ${commit.message}`);
+			break;
+	}
+});
+
+handler.on('issues', event => {
+	const issue = event.issue;
+	const action = event.action;
+	let title: string;
+	switch (action) {
+		case 'opened': title = 'Issue opened'; break;
+		case 'closed': title = 'Issue closed'; break;
+		case 'reopened': title = 'Issue reopened'; break;
+		default: return;
+	}
+	post(`${title}: <${issue.number}>「${issue.title}」\n${issue.html_url}`);
+});
+
+handler.on('issue_comment', event => {
+	const issue = event.issue;
+	const comment = event.comment;
+	const action = event.action;
+	let text: string;
+	switch (action) {
+		case 'created': text = `Commented to「${issue.title}」:${comment.user.login}「${comment.body}」\n${comment.html_url}`; break;
+		default: return;
+	}
+	post(text);
+});
+
+handler.on('watch', event => {
+	const sender = event.sender;
+	post(`(((⭐️))) Starred by **${sender.login}** (((⭐️)))`, false);
+});
+
+handler.on('fork', event => {
+	const repo = event.forkee;
+	post(`🍴 Forked:\n${repo.html_url} 🍴`);
+});
+
+handler.on('pull_request', event => {
+	const pr = event.pull_request;
+	const action = event.action;
+	let text: string;
+	switch (action) {
+		case 'opened': text = `New Pull Request:「${pr.title}」\n${pr.html_url}`; break;
+		case 'reopened': text = `Pull Request Reopened:「${pr.title}」\n${pr.html_url}`; break;
+		case 'closed':
+			text = pr.merged
+				? `Pull Request Merged!:「${pr.title}」\n${pr.html_url}`
+				: `Pull Request Closed:「${pr.title}」\n${pr.html_url}`;
+			break;
+		default: return;
+	}
+	post(text);
+});
diff --git a/src/server/api/service/github.ts b/src/server/api/service/github.ts
index 617bd7d08..4dce856c2 100644
--- a/src/server/api/service/github.ts
+++ b/src/server/api/service/github.ts
@@ -1,37 +1,14 @@
-import * as EventEmitter from 'events';
 import * as Koa from 'koa';
 import * as Router from 'koa-router';
 import * as request from 'request';
 import { OAuth2 } from 'oauth';
-import User, { IUser, pack, ILocalUser } from '../../../models/user';
-import createNote from '../../../services/note/create';
+import User, { pack, ILocalUser } from '../../../models/user';
 import config from '../../../config';
 import { publishMainStream } from '../../../stream';
 import redis from '../../../db/redis';
 import uuid = require('uuid');
 import signin from '../common/signin';
-const crypto = require('crypto');
-
-const handler = new EventEmitter();
-
-let bot: IUser;
-
-const post = async (text: string, home = true) => {
-	if (bot == null) {
-		const account = await User.findOne({
-			usernameLower: config.github_bot.username.toLowerCase()
-		});
-
-		if (account == null) {
-			console.warn(`GitHub hook bot specified, but not found: @${config.github_bot.username}`);
-			return;
-		} else {
-			bot = account;
-		}
-	}
-
-	createNote(bot, { text, visibility: home ? 'home' : 'public' });
-};
+import fetchMeta from '../../../misc/fetch-meta';
 
 function getUserToken(ctx: Koa.Context) {
 	return ((ctx.headers['cookie'] || '').match(/i=(!\w+)/) || [null, null])[1];
@@ -80,337 +57,218 @@ router.get('/disconnect/github', async ctx => {
 	}));
 });
 
-if (!config.github || !redis) {
-	router.get('/connect/github', ctx => {
-		ctx.body = '現在GitHubへ接続できません (このインスタンスではGitHubはサポートされていません)';
+async function getOath2() {
+	const meta = await fetchMeta();
+
+	if (meta.enableGithubIntegration) {
+		return new OAuth2(
+			meta.githubClientId,
+			meta.githubClientSecret,
+			'https://github.com/',
+			'login/oauth/authorize',
+			'login/oauth/access_token');
+	} else {
+		return null;
+	}
+}
+
+router.get('/connect/github', async ctx => {
+	if (!compareOrigin(ctx)) {
+		ctx.throw(400, 'invalid origin');
+		return;
+	}
+
+	const userToken = getUserToken(ctx);
+	if (!userToken) {
+		ctx.throw(400, 'signin required');
+		return;
+	}
+
+	const params = {
+		redirect_uri: `${config.url}/api/gh/cb`,
+		scope: ['read:user'],
+		state: uuid()
+	};
+
+	redis.set(userToken, JSON.stringify(params));
+
+	const oauth2 = await getOath2();
+	ctx.redirect(oauth2.getAuthorizeUrl(params));
+});
+
+router.get('/signin/github', async ctx => {
+	const sessid = uuid();
+
+	const params = {
+		redirect_uri: `${config.url}/api/gh/cb`,
+		scope: ['read:user'],
+		state: uuid()
+	};
+
+	const expires = 1000 * 60 * 60; // 1h
+	ctx.cookies.set('signin_with_github_session_id', sessid, {
+		path: '/',
+		domain: config.host,
+		secure: config.url.startsWith('https'),
+		httpOnly: true,
+		expires: new Date(Date.now() + expires),
+		maxAge: expires
 	});
 
-	router.get('/signin/github', ctx => {
-		ctx.body = '現在GitHubへ接続できません (このインスタンスではGitHubはサポートされていません)';
-	});
-} else {
-	const oauth2 = new OAuth2(
-		config.github.client_id,
-		config.github.client_secret,
-		'https://github.com/',
-		'login/oauth/authorize',
-		'login/oauth/access_token');
+	redis.set(sessid, JSON.stringify(params));
 
-	router.get('/connect/github', async ctx => {
-		if (!compareOrigin(ctx)) {
-			ctx.throw(400, 'invalid origin');
+	const oauth2 = await getOath2();
+	ctx.redirect(oauth2.getAuthorizeUrl(params));
+});
+
+router.get('/gh/cb', async ctx => {
+	const userToken = getUserToken(ctx);
+
+	const oauth2 = await getOath2();
+
+	if (!userToken) {
+		const sessid = ctx.cookies.get('signin_with_github_session_id');
+
+		if (!sessid) {
+			ctx.throw(400, 'invalid session');
 			return;
 		}
 
-		const userToken = getUserToken(ctx);
-		if (!userToken) {
-			ctx.throw(400, 'signin required');
+		const code = ctx.query.code;
+
+		if (!code) {
+			ctx.throw(400, 'invalid session');
 			return;
 		}
 
-		const params = {
-			redirect_uri: `${config.url}/api/gh/cb`,
-			scope: ['read:user'],
-			state: uuid()
-		};
-
-		redis.set(userToken, JSON.stringify(params));
-		ctx.redirect(oauth2.getAuthorizeUrl(params));
-	});
-
-	router.get('/signin/github', async ctx => {
-		const sessid = uuid();
-
-		const params = {
-			redirect_uri: `${config.url}/api/gh/cb`,
-			scope: ['read:user'],
-			state: uuid()
-		};
-
-		const expires = 1000 * 60 * 60; // 1h
-		ctx.cookies.set('signin_with_github_session_id', sessid, {
-			path: '/',
-			domain: config.host,
-			secure: config.url.startsWith('https'),
-			httpOnly: true,
-			expires: new Date(Date.now() + expires),
-			maxAge: expires
+		const { redirect_uri, state } = await new Promise<any>((res, rej) => {
+			redis.get(sessid, async (_, state) => {
+				res(JSON.parse(state));
+			});
 		});
 
-		redis.set(sessid, JSON.stringify(params));
-		ctx.redirect(oauth2.getAuthorizeUrl(params));
-	});
+		if (ctx.query.state !== state) {
+			ctx.throw(400, 'invalid session');
+			return;
+		}
 
-	router.get('/gh/cb', async ctx => {
-		const userToken = getUserToken(ctx);
-
-		if (!userToken) {
-			const sessid = ctx.cookies.get('signin_with_github_session_id');
-
-			if (!sessid) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const code = ctx.query.code;
-
-			if (!code) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const { redirect_uri, state } = await new Promise<any>((res, rej) => {
-				redis.get(sessid, async (_, state) => {
-					res(JSON.parse(state));
-				});
-			});
-
-			if (ctx.query.state !== state) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const { accessToken } = await new Promise<any>((res, rej) =>
-				oauth2.getOAuthAccessToken(
-					code,
-					{ redirect_uri },
-					(err, accessToken, refresh, result) => {
-						if (err)
-							rej(err);
-						else if (result.error)
-							rej(result.error);
-						else
-							res({ accessToken });
-					}));
-
-			const { login, id } = await new Promise<any>((res, rej) =>
-				request({
-					url: 'https://api.github.com/user',
-					headers: {
-						'Accept': 'application/vnd.github.v3+json',
-						'Authorization': `bearer ${accessToken}`,
-						'User-Agent': config.user_agent
-					}
-				}, (err, response, body) => {
+		const { accessToken } = await new Promise<any>((res, rej) =>
+			oauth2.getOAuthAccessToken(
+				code,
+				{ redirect_uri },
+				(err, accessToken, refresh, result) => {
 					if (err)
 						rej(err);
+					else if (result.error)
+						rej(result.error);
 					else
-						res(JSON.parse(body));
+						res({ accessToken });
 				}));
 
-			if (!login || !id) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const user = await User.findOne({
-				host: null,
-				'github.id': id
-			}) as ILocalUser;
-
-			if (!user) {
-				ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`);
-				return;
-			}
-
-			signin(ctx, user, true);
-		} else {
-			const code = ctx.query.code;
-
-			if (!code) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const { redirect_uri, state } = await new Promise<any>((res, rej) => {
-				redis.get(userToken, async (_, state) => {
-					res(JSON.parse(state));
-				});
-			});
-
-			if (ctx.query.state !== state) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const { accessToken } = await new Promise<any>((res, rej) =>
-				oauth2.getOAuthAccessToken(
-					code,
-					{ redirect_uri },
-					(err, accessToken, refresh, result) => {
-						if (err)
-							rej(err);
-						else if (result.error)
-							rej(result.error);
-						else
-							res({ accessToken });
-					}));
-
-			const { login, id } = await new Promise<any>((res, rej) =>
-				request({
-					url: 'https://api.github.com/user',
-					headers: {
-						'Accept': 'application/vnd.github.v3+json',
-						'Authorization': `bearer ${accessToken}`,
-						'User-Agent': config.user_agent
-					}
-				}, (err, response, body) => {
-					if (err)
-						rej(err);
-					else
-						res(JSON.parse(body));
-				}));
-
-			if (!login || !id) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const user = await User.findOneAndUpdate({
-				host: null,
-				token: userToken
-			}, {
-				$set: {
-					github: {
-						accessToken,
-						id,
-						login
-					}
+		const { login, id } = await new Promise<any>((res, rej) =>
+			request({
+				url: 'https://api.github.com/user',
+				headers: {
+					'Accept': 'application/vnd.github.v3+json',
+					'Authorization': `bearer ${accessToken}`,
+					'User-Agent': config.user_agent
 				}
-			});
-
-			ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`;
-
-			// Publish i updated event
-			publishMainStream(user._id, 'meUpdated', await pack(user, user, {
-				detail: true,
-				includeSecrets: true
+			}, (err, response, body) => {
+				if (err)
+					rej(err);
+				else
+					res(JSON.parse(body));
 			}));
+
+		if (!login || !id) {
+			ctx.throw(400, 'invalid session');
+			return;
 		}
-	});
-}
 
-if (config.github_bot) {
-	const secret = config.github_bot.hook_secret;
+		const user = await User.findOne({
+			host: null,
+			'github.id': id
+		}) as ILocalUser;
 
-	router.post('/hooks/github', ctx => {
-		const body = JSON.stringify(ctx.request.body);
-		const hash = crypto.createHmac('sha1', secret).update(body).digest('hex');
-		const sig1 = new Buffer(ctx.headers['x-hub-signature']);
-		const sig2 = new Buffer(`sha1=${hash}`);
-
-		// シグネチャ比較
-		if (sig1.equals(sig2)) {
-			handler.emit(ctx.headers['x-github-event'], ctx.request.body);
-			ctx.status = 204;
-		} else {
-			ctx.status = 400;
+		if (!user) {
+			ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`);
+			return;
 		}
-	});
-}
+
+		signin(ctx, user, true);
+	} else {
+		const code = ctx.query.code;
+
+		if (!code) {
+			ctx.throw(400, 'invalid session');
+			return;
+		}
+
+		const { redirect_uri, state } = await new Promise<any>((res, rej) => {
+			redis.get(userToken, async (_, state) => {
+				res(JSON.parse(state));
+			});
+		});
+
+		if (ctx.query.state !== state) {
+			ctx.throw(400, 'invalid session');
+			return;
+		}
+
+		const { accessToken } = await new Promise<any>((res, rej) =>
+			oauth2.getOAuthAccessToken(
+				code,
+				{ redirect_uri },
+				(err, accessToken, refresh, result) => {
+					if (err)
+						rej(err);
+					else if (result.error)
+						rej(result.error);
+					else
+						res({ accessToken });
+				}));
+
+		const { login, id } = await new Promise<any>((res, rej) =>
+			request({
+				url: 'https://api.github.com/user',
+				headers: {
+					'Accept': 'application/vnd.github.v3+json',
+					'Authorization': `bearer ${accessToken}`,
+					'User-Agent': config.user_agent
+				}
+			}, (err, response, body) => {
+				if (err)
+					rej(err);
+				else
+					res(JSON.parse(body));
+			}));
+
+		if (!login || !id) {
+			ctx.throw(400, 'invalid session');
+			return;
+		}
+
+		const user = await User.findOneAndUpdate({
+			host: null,
+			token: userToken
+		}, {
+			$set: {
+				github: {
+					accessToken,
+					id,
+					login
+				}
+			}
+		});
+
+		ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`;
+
+		// Publish i updated event
+		publishMainStream(user._id, 'meUpdated', await pack(user, user, {
+			detail: true,
+			includeSecrets: true
+		}));
+	}
+});
 
 module.exports = router;
-
-handler.on('status', event => {
-	const state = event.state;
-	switch (state) {
-		case 'error':
-		case 'failure':
-			const commit = event.commit;
-			const parent = commit.parents[0];
-
-			// Fetch parent status
-			request({
-				url: `${parent.url}/statuses`,
-				proxy: config.proxy,
-				headers: {
-					'User-Agent': 'misskey'
-				}
-			}, (err, res, body) => {
-				if (err) {
-					console.error(err);
-					return;
-				}
-				const parentStatuses = JSON.parse(body);
-				const parentState = parentStatuses[0].state;
-				const stillFailed = parentState == 'failure' || parentState == 'error';
-				if (stillFailed) {
-					post(`**⚠️BUILD STILL FAILED⚠️**: ?[${commit.commit.message}](${commit.html_url})`);
-				} else {
-					post(`**🚨BUILD FAILED🚨**: →→→?[${commit.commit.message}](${commit.html_url})←←←`);
-				}
-			});
-			break;
-	}
-});
-
-handler.on('push', event => {
-	const ref = event.ref;
-	switch (ref) {
-		case 'refs/heads/master':
-			const pusher = event.pusher;
-			const compare = event.compare;
-			const commits: any[] = event.commits;
-			post([
-				`Pushed by **${pusher.name}** with ?[${commits.length} commit${commits.length > 1 ? 's' : ''}](${compare}):`,
-				commits.reverse().map(commit => `・[?[${commit.id.substr(0, 7)}](${commit.url})] ${commit.message.split('\n')[0]}`).join('\n'),
-			].join('\n'));
-			break;
-		case 'refs/heads/release':
-			const commit = event.commits[0];
-			post(`RELEASED: ${commit.message}`);
-			break;
-	}
-});
-
-handler.on('issues', event => {
-	const issue = event.issue;
-	const action = event.action;
-	let title: string;
-	switch (action) {
-		case 'opened': title = 'Issue opened'; break;
-		case 'closed': title = 'Issue closed'; break;
-		case 'reopened': title = 'Issue reopened'; break;
-		default: return;
-	}
-	post(`${title}: <${issue.number}>「${issue.title}」\n${issue.html_url}`);
-});
-
-handler.on('issue_comment', event => {
-	const issue = event.issue;
-	const comment = event.comment;
-	const action = event.action;
-	let text: string;
-	switch (action) {
-		case 'created': text = `Commented to「${issue.title}」:${comment.user.login}「${comment.body}」\n${comment.html_url}`; break;
-		default: return;
-	}
-	post(text);
-});
-
-handler.on('watch', event => {
-	const sender = event.sender;
-	post(`(((⭐️))) Starred by **${sender.login}** (((⭐️)))`, false);
-});
-
-handler.on('fork', event => {
-	const repo = event.forkee;
-	post(`🍴 Forked:\n${repo.html_url} 🍴`);
-});
-
-handler.on('pull_request', event => {
-	const pr = event.pull_request;
-	const action = event.action;
-	let text: string;
-	switch (action) {
-		case 'opened': text = `New Pull Request:「${pr.title}」\n${pr.html_url}`; break;
-		case 'reopened': text = `Pull Request Reopened:「${pr.title}」\n${pr.html_url}`; break;
-		case 'closed':
-			text = pr.merged
-				? `Pull Request Merged!:「${pr.title}」\n${pr.html_url}`
-				: `Pull Request Closed:「${pr.title}」\n${pr.html_url}`;
-			break;
-		default: return;
-	}
-	post(text);
-});
diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts
index 6c3cdaa13..ced3e8acc 100644
--- a/src/server/api/service/twitter.ts
+++ b/src/server/api/service/twitter.ts
@@ -7,6 +7,7 @@ import User, { pack, ILocalUser } from '../../../models/user';
 import { publishMainStream } from '../../../stream';
 import config from '../../../config';
 import signin from '../common/signin';
+import fetchMeta from '../../../misc/fetch-meta';
 
 function getUserToken(ctx: Koa.Context) {
 	return ((ctx.headers['cookie'] || '').match(/i=(!\w+)/) || [null, null])[1];
@@ -55,131 +56,133 @@ router.get('/disconnect/twitter', async ctx => {
 	}));
 });
 
-if (config.twitter == null || redis == null) {
-	router.get('/connect/twitter', ctx => {
-		ctx.body = '現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)';
-	});
+async function getTwAuth() {
+	const meta = await fetchMeta();
 
-	router.get('/signin/twitter', ctx => {
-		ctx.body = '現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)';
-	});
-} else {
-	const twAuth = autwh({
-		consumerKey: config.twitter.consumer_key,
-		consumerSecret: config.twitter.consumer_secret,
-		callbackUrl: `${config.url}/api/tw/cb`
-	});
-
-	router.get('/connect/twitter', async ctx => {
-		if (!compareOrigin(ctx)) {
-			ctx.throw(400, 'invalid origin');
-			return;
-		}
-
-		const userToken = getUserToken(ctx);
-		if (userToken == null) {
-			ctx.throw(400, 'signin required');
-			return;
-		}
-
-		const twCtx = await twAuth.begin();
-		redis.set(userToken, JSON.stringify(twCtx));
-		ctx.redirect(twCtx.url);
-	});
-
-	router.get('/signin/twitter', async ctx => {
-		const twCtx = await twAuth.begin();
-
-		const sessid = uuid();
-
-		redis.set(sessid, JSON.stringify(twCtx));
-
-		const expires = 1000 * 60 * 60; // 1h
-		ctx.cookies.set('signin_with_twitter_session_id', sessid, {
-			path: '/',
-			domain: config.host,
-			secure: config.url.startsWith('https'),
-			httpOnly: true,
-			expires: new Date(Date.now() + expires),
-			maxAge: expires
+	if (meta.enableTwitterIntegration) {
+		return autwh({
+			consumerKey: meta.twitterConsumerKey,
+			consumerSecret: meta.twitterConsumerSecret,
+			callbackUrl: `${config.url}/api/tw/cb`
 		});
-
-		ctx.redirect(twCtx.url);
-	});
-
-	router.get('/tw/cb', async ctx => {
-		const userToken = getUserToken(ctx);
-
-		if (userToken == null) {
-			const sessid = ctx.cookies.get('signin_with_twitter_session_id');
-
-			if (sessid == null) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const get = new Promise<any>((res, rej) => {
-				redis.get(sessid, async (_, twCtx) => {
-					res(twCtx);
-				});
-			});
-
-			const twCtx = await get;
-
-			const result = await twAuth.done(JSON.parse(twCtx), ctx.query.oauth_verifier);
-
-			const user = await User.findOne({
-				host: null,
-				'twitter.userId': result.userId
-			}) as ILocalUser;
-
-			if (user == null) {
-				ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`);
-				return;
-			}
-
-			signin(ctx, user, true);
-		} else {
-			const verifier = ctx.query.oauth_verifier;
-
-			if (verifier == null) {
-				ctx.throw(400, 'invalid session');
-				return;
-			}
-
-			const get = new Promise<any>((res, rej) => {
-				redis.get(userToken, async (_, twCtx) => {
-					res(twCtx);
-				});
-			});
-
-			const twCtx = await get;
-
-			const result = await twAuth.done(JSON.parse(twCtx), verifier);
-
-			const user = await User.findOneAndUpdate({
-				host: null,
-				token: userToken
-			}, {
-				$set: {
-					twitter: {
-						accessToken: result.accessToken,
-						accessTokenSecret: result.accessTokenSecret,
-						userId: result.userId,
-						screenName: result.screenName
-					}
-				}
-			});
-
-			ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`;
-
-			// Publish i updated event
-			publishMainStream(user._id, 'meUpdated', await pack(user, user, {
-				detail: true,
-				includeSecrets: true
-			}));
-		}
-	});
+	} else {
+		return null;
+	}
 }
 
+router.get('/connect/twitter', async ctx => {
+	if (!compareOrigin(ctx)) {
+		ctx.throw(400, 'invalid origin');
+		return;
+	}
+
+	const userToken = getUserToken(ctx);
+	if (userToken == null) {
+		ctx.throw(400, 'signin required');
+		return;
+	}
+
+	const twAuth = await getTwAuth();
+	const twCtx = await twAuth.begin();
+	redis.set(userToken, JSON.stringify(twCtx));
+	ctx.redirect(twCtx.url);
+});
+
+router.get('/signin/twitter', async ctx => {
+	const twAuth = await getTwAuth();
+	const twCtx = await twAuth.begin();
+
+	const sessid = uuid();
+
+	redis.set(sessid, JSON.stringify(twCtx));
+
+	const expires = 1000 * 60 * 60; // 1h
+	ctx.cookies.set('signin_with_twitter_session_id', sessid, {
+		path: '/',
+		domain: config.host,
+		secure: config.url.startsWith('https'),
+		httpOnly: true,
+		expires: new Date(Date.now() + expires),
+		maxAge: expires
+	});
+
+	ctx.redirect(twCtx.url);
+});
+
+router.get('/tw/cb', async ctx => {
+	const userToken = getUserToken(ctx);
+
+	const twAuth = await getTwAuth();
+
+	if (userToken == null) {
+		const sessid = ctx.cookies.get('signin_with_twitter_session_id');
+
+		if (sessid == null) {
+			ctx.throw(400, 'invalid session');
+			return;
+		}
+
+		const get = new Promise<any>((res, rej) => {
+			redis.get(sessid, async (_, twCtx) => {
+				res(twCtx);
+			});
+		});
+
+		const twCtx = await get;
+
+		const result = await twAuth.done(JSON.parse(twCtx), ctx.query.oauth_verifier);
+
+		const user = await User.findOne({
+			host: null,
+			'twitter.userId': result.userId
+		}) as ILocalUser;
+
+		if (user == null) {
+			ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`);
+			return;
+		}
+
+		signin(ctx, user, true);
+	} else {
+		const verifier = ctx.query.oauth_verifier;
+
+		if (verifier == null) {
+			ctx.throw(400, 'invalid session');
+			return;
+		}
+
+		const get = new Promise<any>((res, rej) => {
+			redis.get(userToken, async (_, twCtx) => {
+				res(twCtx);
+			});
+		});
+
+		const twCtx = await get;
+
+		const result = await twAuth.done(JSON.parse(twCtx), verifier);
+
+		const user = await User.findOneAndUpdate({
+			host: null,
+			token: userToken
+		}, {
+			$set: {
+				twitter: {
+					accessToken: result.accessToken,
+					accessTokenSecret: result.accessTokenSecret,
+					userId: result.userId,
+					screenName: result.screenName
+				}
+			}
+		});
+
+		ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`;
+
+		// Publish i updated event
+		publishMainStream(user._id, 'meUpdated', await pack(user, user, {
+			detail: true,
+			includeSecrets: true
+		}));
+	}
+});
+
 module.exports = router;