1
0
mirror of https://github.com/byulmaru/quesdon synced 2024-11-30 15:58:01 +09:00

Misskey API 호환 코드 추가

This commit is contained in:
DW 2019-11-11 01:32:18 +11:00
parent cdd6f1661a
commit 9132850b16
3 changed files with 251 additions and 92 deletions

View File

@ -3,10 +3,13 @@ import Router from 'koa-router';
import fetch from 'node-fetch';
import parseLinkHeader, { Link, Links } from 'parse-link-header';
import { Account } from 'megalodon';
import { User as MisskeyUser } from '../../utils/misskey_entities/user';
import { Following } from '../../utils/misskey_entities/following';
import { oneLineTrim, stripIndents } from 'common-tags';
import { QUESTION_TEXT_MAX_LENGTH } from '../../../common/const';
import { BASE_URL, NOTICE_ACCESS_TOKEN, PUSHBULLET_CLIENT_ID, PUSHBULLET_CLIENT_SECRET } from '../../config';
import { Question, User } from '../../db/index';
import detectInstance from '../../utils/detectInstance';
const router = new Router();
@ -24,8 +27,6 @@ router.get('/verify_credentials', async (ctx: Koa.ParameterizedContext): Promise
router.get('/followers', async (ctx: Koa.ParameterizedContext): Promise<never|void|{}> =>
{
if (null === /^\d+$/.exec(ctx.query.max_id || '0'))
return ctx.throw('max_id is num only', 400);
if (!ctx.session.user)
return ctx.throw('please login', 403);
@ -37,10 +38,49 @@ router.get('/followers', async (ctx: Koa.ParameterizedContext): Promise<never|vo
if (user.hostName === 'twitter.com')
return ctx.body = { max_id: undefined, accounts: [] };
// TODO: add logic for misskey
// mastodon
const instanceUrl = 'https://' + user.acct.split('@')[1];
const myInfo = await fetch(instanceUrl + '/api/v1/accounts/verify_credentials',
const instanceType = await detectInstance(instanceUrl);
if (instanceType === 'misskey')
{
// misskey
const fetchOptions =
{
method: 'POST',
headers: { 'Content-Type': 'application/json' }
};
const myInfo: MisskeyUser = await fetch(`${instanceUrl}/api/i`,
Object.assign({}, fetchOptions,
{
body: JSON.stringify( { i: user.accessToken })
})).then(r => r.json());
const followersRaw: Following[] = await fetch(`${instanceUrl}/api/users/followers`,
Object.assign({}, fetchOptions,
{
body:
`{
"i": "${user.accessToken}",
"userId": "${myInfo.id}",
"limit": 80
${ctx.query.max_id ? ',"untilId": "' + ctx.query.max_id + '"' : ''}
}`
})).then(r => r.json());
const followers = followersRaw
.map(follower => `${follower.follower?.username}@${follower.follower?.host ?? user.acct.split('@')[1]}`.toLowerCase());
console.log(followers);
const followersObject = await User.find({acctLower: {$in: followers}});
const max_id = (followersRaw[followersRaw.length - 1] ?? { id: '' }).id;
return ctx.body =
{
accounts: followersObject,
max_id
};
}
// mastodon
const myInfo = await fetch(`${instanceUrl}/api/v1/accounts/verify_credentials`,
{
headers: { Authorization: 'Bearer ' + user.accessToken }
}).then((r) => r.json());
@ -57,7 +97,7 @@ router.get('/followers', async (ctx: Koa.ParameterizedContext): Promise<never|vo
const followersObject = await User.find({acctLower: {$in: followers}});
const max_id = ((parseLinkHeader(followersRes.headers.get('Link') ?? '') || {} as Links).next || {} as Link).max_id;
ctx.body =
return ctx.body =
{
accounts: followersObject,
max_id
@ -185,7 +225,7 @@ router.post('/:acct/question', async (ctx: Koa.ParameterizedContext): Promise<ne
if (!ctx.session.user)
return ctx.throw('please login', 403);
const questionUser = await User.findById(ctx.sessions.user);
const questionUser = await User.findById(ctx.session.user);
if (!questionUser)
return ctx.throw('not found', 404);

View File

@ -2,12 +2,16 @@ import Koa from 'koa';
import Router from 'koa-router';
import fetch from 'node-fetch';
import rndstr from 'rndstr';
import crypto from 'crypto';
import { URL } from 'url';
import { BASE_URL } from '../../config';
import { MastodonApp, User } from '../../db/index';
import QueryStringUtils from '../../utils/queryString';
import { requestOAuth } from '../../utils/requestOAuth';
import twitterClient from '../../utils/twitterClient';
import detectInstance from '../../utils/detectInstance';
import { App } from '../../utils/misskey_entities/app';
import { User as MisskeyUser } from '../../utils/misskey_entities/user';
const router = new Router();
@ -20,47 +24,7 @@ router.post('/get_url', async (ctx: Koa.ParameterizedContext): Promise<never|voi
const redirectUri = BASE_URL + '/api/web/oauth/redirect';
let url = '';
if (hostName !== 'twitter.com')
{
// Mastodon
// TODO: misskey support
let app = await MastodonApp.findOne( { hostName, appBaseUrl: BASE_URL, redirectUri } );
if (!app)
{
const res = await fetch('https://' + hostName + '/api/v1/apps',
{
method: 'POST',
body: JSON.stringify(
{
client_name: 'Quesdon',
redirect_uris: redirectUri,
scopes: 'read write',
website: BASE_URL
}),
headers: { 'Content-Type': 'application/json' }
}).then((r) => r.json());
app = new MastodonApp();
app.clientId = res.client_id;
app.clientSecret = res.client_secret;
app.hostName = hostName;
app.appBaseUrl = BASE_URL;
app.redirectUri = redirectUri;
await app.save();
}
ctx.session.loginState = `${rndstr()}_${app.id}`;
const params: {[key: string]: string} =
{
client_id: app.clientId,
scope: 'read+write',
redirect_uri: redirectUri,
response_type: 'code',
state: ctx.session.loginState
};
url = `https://${app.hostName}/oauth/authorize?${Object.entries(params).map((v) => v.join('=')).join('&')}`;
}
else // Twitter
if (hostName === 'twitter.com')
{
ctx.session.loginState = 'twitter';
const { TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET } = process.env;
@ -80,6 +44,85 @@ router.post('/get_url', async (ctx: Koa.ParameterizedContext): Promise<never|voi
ctx.session.twitterOAuth = requestToken;
url = `https://twitter.com/oauth/authenticate?oauth_token=${requestToken.token}`;
}
else
{
const instanceType = await detectInstance(`https://${hostName}`);
if (instanceType === 'misskey')
{
let app = await MastodonApp.findOne( { hostName, appBaseUrl: BASE_URL, redirectUri } );
if (!app) // if it's the first time user from this instance is using quesdon
{
const res: App = await fetch(`https://${hostName}/api/app/create`,
{
method: 'POST',
body: JSON.stringify(
{
name: 'Quesdon',
description: BASE_URL,
permission: ['read:following', 'write:notes'],
callbackUrl: redirectUri
}),
headers: { 'Content-Type': 'application/json' }
}).then(r => r.json());
app = new MastodonApp();
app.clientId = res.id,
app.clientSecret = res.secret as string;
app.hostName = hostName;
app.appBaseUrl = BASE_URL;
app.redirectUri = redirectUri;
await app.save();
}
ctx.session.loginState = `misskey_${app.id}`;
const res = await fetch(`https://${hostName}/api/auth/session/generate`, // get authentication url from misskey instance
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( { appSecret: app.clientSecret } )
}).then(r => r.json());
url = res.url;
}
else
{
// Mastodon
let app = await MastodonApp.findOne( { hostName, appBaseUrl: BASE_URL, redirectUri } );
if (!app)
{
const res = await fetch('https://' + hostName + '/api/v1/apps',
{
method: 'POST',
body: JSON.stringify(
{
client_name: 'Quesdon',
redirect_uris: redirectUri,
scopes: 'read write',
website: BASE_URL
}),
headers: { 'Content-Type': 'application/json' }
}).then((r) => r.json());
app = new MastodonApp();
app.clientId = res.client_id;
app.clientSecret = res.client_secret;
app.hostName = hostName;
app.appBaseUrl = BASE_URL;
app.redirectUri = redirectUri;
await app.save();
}
ctx.session.loginState = `${rndstr()}_${app.id}`;
const params: {[key: string]: string} =
{
client_id: app.clientId,
scope: 'read+write',
redirect_uri: redirectUri,
response_type: 'code',
state: ctx.session.loginState
};
url = `https://${app.hostName}/oauth/authorize?${Object.entries(params).map((v) => v.join('=')).join('&')}`;
}
}
ctx.body = { url };
});
@ -96,11 +139,40 @@ router.get('/redirect', async (ctx: Koa.ParameterizedContext) =>
url: string;
acct: string;
};
if (ctx.session.loginState !== 'twitter')
if ((ctx.session.loginState as string).startsWith('misskey'))
{
// misskey
const app = await MastodonApp.findById(ctx.session.loginState.split('_')[1]);
if (app === null)
return ctx.redirect('/login?error=app_notfound');
const res: { accessToken: string; user: MisskeyUser } = await fetch(`https://${app.hostName}/api/auth/session/userkey`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
{
appSecret: app.clientSecret,
token: ctx.query.token
})
}).then(r => r.json());
profile =
{
id: res.user.id,
name: res.user.name ?? res.user.username,
screenName: res.user.username,
hostName: app.hostName,
avatarUrl: res.user.avatarUrl as string,
accessToken: crypto.createHash('sha256').update(res.accessToken + app.clientSecret).digest('hex'),
url: `https://${res.user.host}/@${res.user.username}`,
acct: `${res.user.username}@${app.hostName}`
};
}
else if (ctx.session.loginState !== 'twitter')
{
// Mastodon
// TODO: handle misskey
if (ctx.query.state !== ctx.session.loginState)
return ctx.redirect('/login?error=invalid_state');
@ -209,8 +281,7 @@ router.get('/redirect', async (ctx: Koa.ParameterizedContext) =>
let user;
if (profile.hostName !== 'twitter.com')
{
// Mastodon
// TODO: misskey
// Mastodon and misskey
user = await User.findOne({acctLower: acct.toLowerCase()});
}
else

View File

@ -8,6 +8,7 @@ import { IMastodonApp, IUser, Question, QuestionLike, User } from '../../db/inde
import { cutText } from '../../utils/cutText';
import { requestOAuth } from '../../utils/requestOAuth';
import twitterClient from '../../utils/twitterClient';
import detectInstance from '../../utils/detectInstance';
const router = new Router();
@ -52,7 +53,7 @@ router.get('/latest', async (ctx) =>
ctx.body = questions;
});
router.post('/:id/answer', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
router.post('/:id/answer', async (ctx: Koa.ParameterizedContext): Promise<void|never> =>
{
if (!ctx.session.user)
return ctx.throw('please login', 403);
@ -87,57 +88,104 @@ router.post('/:id/answer', async (ctx: Koa.ParameterizedContext): Promise<never|
const isTwitter = user.hostName === 'twitter.com';
const answerCharMax = isTwitter ? (110 - question.question.length) : 200;
const answerUrl = `${BASE_URL}/@${user.acct}/questions/${question.id}`;
if (!isTwitter)
if (isTwitter)
{
// Mastodon
// TODO: misskey
const body =
{
spoiler_text: `Q. ${question.question} #quesdon`,
status: `A. ${question.answer.length > 200 ? `${question.answer.substring(0, 200)}...` : question.answer}
#quesdon ${answerUrl}`,
visibility: ctx.request.body.visibility
};
if (question.questionUser)
{
let questionUserAcct = `@${question.questionUser.acct}`;
if (question.questionUser.hostName === 'twitter.com')
questionUserAcct = `https://twitter.com/${question.questionUser.acct.replace(/:.+/, '')}`;
body.status = stripIndents`질문자: ${questionUserAcct}
${body.status}`;
}
if (question.isNSFW)
{
body.status = `Q. ${question.question}
${body.status}`;
body.spoiler_text = '⚠ 이 질문은 답변자가 NSFW하다고 했어요. #quesdon';
}
fetch('https://' + user.acct.split('@')[1] + '/api/v1/statuses',
{
method: 'POST',
body: JSON.stringify(body),
headers:
{
'Authorization': 'Bearer ' + user.accessToken,
'Content-Type': 'application/json'
}
});
}
else
{
const strQ = cutText(question.question, 60);
const strA = cutText(question.answer, 120 - strQ.length);
const [key, secret] = user.accessToken.split(':');
const body = `Q. ${strQ}
A. ${strA}
#quesdon ${answerUrl}`;
requestOAuth(twitterClient,
await requestOAuth(twitterClient,
{
url: 'https://api.twitter.com/1.1/statuses/update.json',
method: 'POST',
data: { status: body }
}, { key, secret });
return;
}
// misskey
const instanceUrl = 'https://' + user.acct.split('@')[1];
const instanceType = await detectInstance(instanceUrl);
if (instanceType === 'misskey')
{
let visibility;
switch(ctx.request.body.visibility)
{
case 'public': visibility = 'public'; break;
case 'unlisted': visibility = 'home'; break;
case 'private': visibility = 'followers'; break;
default: visibility = 'home'; break;
}
const body =
{
i: user.accessToken,
visibility: visibility,
cw: `Q. ${question.question} #quesdon`,
text: stripIndents`A. ${question.answer.length > 200 ? `${question.answer.substring(0, 200)}...` : question.answer}
#quesdon ${answerUrl}`
};
if (question.questionUser)
{
let questionUserAcct = `@${question.questionUser.acct}`;
if (question.questionUser.hostName === 'twitter.com')
questionUserAcct = `https://twitter.com/${question.questionUser.acct.replace(/:.+/, '')}`;
body.text = stripIndents`질문자: ${questionUserAcct}
${body.text}`;
}
if (question.isNSFW)
{
body.text = stripIndents`Q. ${question.question}
${body.text}`;
body.cw = '⚠ 이 질문은 답변자가 NSFW하다고 했어요. #quesdon';
}
await fetch(`${instanceUrl}/api/notes/create`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return;
}
// Mastodon
const body =
{
spoiler_text: `Q. ${question.question} #quesdon`,
status: stripIndents`A. ${question.answer.length > 200 ? `${question.answer.substring(0, 200)}...` : question.answer}
#quesdon ${answerUrl}`,
visibility: ctx.request.body.visibility
};
if (question.questionUser)
{
let questionUserAcct = `@${question.questionUser.acct}`;
if (question.questionUser.hostName === 'twitter.com')
questionUserAcct = `https://twitter.com/${question.questionUser.acct.replace(/:.+/, '')}`;
body.status = stripIndents`질문자: ${questionUserAcct}
${body.status}`;
}
if (question.isNSFW)
{
body.status = stripIndents`Q. ${question.question}
${body.status}`;
body.spoiler_text = '⚠ 이 질문은 답변자가 NSFW하다고 했어요. #quesdon';
}
await fetch(instanceUrl + '/api/v1/statuses',
{
method: 'POST',
body: JSON.stringify(body),
headers:
{
'Authorization': 'Bearer ' + user.accessToken,
'Content-Type': 'application/json'
}
});
return;
});
router.post('/:id/delete', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>