リモートで投票を見たりしたりできるように (#3940)

* fix type

* expose Question

* Note refs Question

* rename

* wip

* リモート投票の場合リプライ送信

* voteの実装をservicesに移動

* 投票受信

* debug

* つくる

* Revert "つくる"

This reverts commit 0c92458866.

* APIの実装はもどし

* Send Update

* AP type

* Recv Update

* Revert "Recv Update"

This reverts commit ffda39c093.

* Revert "AP type"

This reverts commit 63d8bbe29d.

* Revert "Send Update"

This reverts commit 171b046de5.

* リモートで投票を見る

* 投票はDM

* Provides choices as text for AP

* 絵文字

* fix error

* revert

* APからには不要な処理を削除

* Revert "APからには不要な処理を削除"

This reverts commit 8b5d8af9b0.

* てぬき

* めんどい

* ちっ

* remove unused code
This commit is contained in:
MeiMei 2019-01-21 13:27:19 +09:00 committed by syuilo
parent 6bbccedb2d
commit 4a57482216
10 changed files with 208 additions and 7 deletions

View file

@ -38,11 +38,7 @@ export type INote = {
fileIds: mongo.ObjectID[]; fileIds: mongo.ObjectID[];
replyId: mongo.ObjectID; replyId: mongo.ObjectID;
renoteId: mongo.ObjectID; renoteId: mongo.ObjectID;
poll: { poll: IPoll;
choices: Array<{
id: number;
}>
};
text: string; text: string;
tags: string[]; tags: string[];
tagsLower: string[]; tagsLower: string[];
@ -102,6 +98,16 @@ export type INote = {
_files?: IDriveFile[]; _files?: IDriveFile[];
}; };
export type IPoll = {
choices: IChoice[]
};
export type IChoice = {
id: number;
text: string;
votes: number;
};
export const hideNote = async (packedNote: any, meId: mongo.ObjectID) => { export const hideNote = async (packedNote: any, meId: mongo.ObjectID) => {
let hide = false; let hide = false;

View file

@ -14,6 +14,8 @@ import Emoji, { IEmoji } from '../../../models/emoji';
import { ITag } from './tag'; import { ITag } from './tag';
import { toUnicode } from 'punycode'; import { toUnicode } from 'punycode';
import { unique, concat, difference } from '../../../prelude/array'; import { unique, concat, difference } from '../../../prelude/array';
import { extractPollFromQuestion } from './question';
import vote from '../../../services/note/polls/vote';
const log = debug('misskey:activitypub'); const log = debug('misskey:activitypub');
@ -110,6 +112,16 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
// テキストのパース // テキストのパース
const text = note._misskey_content ? note._misskey_content : htmlToMFM(note.content); const text = note._misskey_content ? note._misskey_content : htmlToMFM(note.content);
// vote
if (reply && reply.poll && text != null) {
const m = text.match(/([0-9])$/);
if (m) {
log(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${m[0]}`);
await vote(actor, reply, Number(m[1]));
return null;
}
}
const emojis = await extractEmojis(note.tag, actor.host).catch(e => { const emojis = await extractEmojis(note.tag, actor.host).catch(e => {
console.log(`extractEmojis: ${e}`); console.log(`extractEmojis: ${e}`);
return [] as IEmoji[]; return [] as IEmoji[];
@ -117,6 +129,9 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
const apEmojis = emojis.map(emoji => emoji.name); const apEmojis = emojis.map(emoji => emoji.name);
const questionUri = note._misskey_question;
const poll = questionUri ? await extractPollFromQuestion(questionUri).catch(() => undefined) : undefined;
// ユーザーの情報が古かったらついでに更新しておく // ユーザーの情報が古かったらついでに更新しておく
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
updatePerson(note.attributedTo); updatePerson(note.attributedTo);
@ -137,6 +152,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
apMentions, apMentions,
apHashtags, apHashtags,
apEmojis, apEmojis,
questionUri,
poll,
uri: note.id uri: note.id
}, silent); }, silent);
} }

View file

@ -0,0 +1,19 @@
import { IChoice, IPoll } from '../../../models/note';
import Resolver from '../resolver';
export async function extractPollFromQuestion(questionUri: string): Promise<IPoll> {
const resolver = new Resolver();
const question = await resolver.resolve(questionUri) as any;
const choices: IChoice[] = question.oneOf.map((x: any, i: number) => {
return {
id: i,
text: x.name,
votes: x._misskey_votes || 0,
} as IChoice;
});
return {
choices
};
}

View file

@ -93,17 +93,27 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
let text = note.text; let text = note.text;
let question: string;
if (note.poll != null) { if (note.poll != null) {
if (text == null) text = ''; if (text == null) text = '';
const url = `${config.url}/notes/${note._id}`; const url = `${config.url}/notes/${note._id}`;
// TODO: i18n // TODO: i18n
text += `\n\n[投票を見る](${url})`; text += `\n\n[リモートで投票を見る](${url})`;
question = `${config.url}/questions/${note._id}`;
} }
let apText = text; let apText = text;
if (apText == null) apText = '';
// Provides choices as text for AP
if (note.poll != null) {
const cs = note.poll.choices.map(c => `${c.id}: ${c.text}`);
apText += '\n';
apText += cs.join('\n');
}
if (quote) { if (quote) {
if (apText == null) apText = '';
apText += `\n\nRE: ${quote}`; apText += `\n\nRE: ${quote}`;
} }
@ -130,6 +140,7 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
content, content,
_misskey_content: text, _misskey_content: text,
_misskey_quote: quote, _misskey_quote: quote,
_misskey_question: question,
published: note.createdAt.toISOString(), published: note.createdAt.toISOString(),
to, to,
cc, cc,

View file

@ -0,0 +1,20 @@
import config from '../../../config';
import { ILocalUser } from '../../../models/user';
import { INote } from '../../../models/note';
export default async function renderQuestion(user: ILocalUser, note: INote) {
const question = {
type: 'Question',
id: `${config.url}/questions/${note._id}`,
actor: `${config.url}/users/${user._id}`,
content: note.text != null ? note.text : '',
oneOf: note.poll.choices.map(c => {
return {
name: c.text,
_misskey_votes: c.votes,
};
}),
};
return question;
}

View file

@ -42,6 +42,7 @@ export interface INote extends IObject {
type: 'Note'; type: 'Note';
_misskey_content: string; _misskey_content: string;
_misskey_quote: string; _misskey_quote: string;
_misskey_question: string;
} }
export interface IPerson extends IObject { export interface IPerson extends IObject {

View file

@ -16,6 +16,7 @@ import Outbox, { packActivity } from './activitypub/outbox';
import Followers from './activitypub/followers'; import Followers from './activitypub/followers';
import Following from './activitypub/following'; import Following from './activitypub/following';
import Featured from './activitypub/featured'; import Featured from './activitypub/featured';
import renderQuestion from '../remote/activitypub/renderer/question';
// Init router // Init router
const router = new Router(); const router = new Router();
@ -110,6 +111,36 @@ router.get('/notes/:note/activity', async ctx => {
setResponseType(ctx); setResponseType(ctx);
}); });
// question
router.get('/questions/:question', async (ctx, next) => {
if (!ObjectID.isValid(ctx.params.question)) {
ctx.status = 404;
return;
}
const poll = await Note.findOne({
_id: new ObjectID(ctx.params.question),
visibility: { $in: ['public', 'home'] },
localOnly: { $ne: true },
poll: {
$exists: true,
$ne: null
},
});
if (poll === null) {
ctx.status = 404;
return;
}
const user = await User.findOne({
_id: poll.userId
});
ctx.body = pack(await renderQuestion(user as ILocalUser, poll));
setResponseType(ctx);
});
// outbox // outbox
router.get('/users/:user/outbox', Outbox); router.get('/users/:user/outbox', Outbox);

View file

@ -6,6 +6,8 @@ import watch from '../../../../../services/note/watch';
import { publishNoteStream } from '../../../../../stream'; import { publishNoteStream } from '../../../../../stream';
import notify from '../../../../../notify'; import notify from '../../../../../notify';
import define from '../../../define'; import define from '../../../define';
import createNote from '../../../../../services/note/create';
import User from '../../../../../models/user';
export const meta = { export const meta = {
desc: { desc: {
@ -114,4 +116,19 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => {
if (user.settings.autoWatch !== false) { if (user.settings.autoWatch !== false) {
watch(user._id, note); watch(user._id, note);
} }
// リモート投票の場合リプライ送信
if (note._user.host != null) {
const pollOwner = await User.findOne({
_id: note.userId
});
createNote(user, {
createdAt: new Date(),
text: ps.choice.toString(),
reply: note,
visibility: 'specified',
visibleUsers: [ pollOwner ],
});
}
})); }));

View file

@ -103,6 +103,7 @@ type Option = {
apMentions?: IUser[]; apMentions?: IUser[];
apHashtags?: string[]; apHashtags?: string[];
apEmojis?: string[]; apEmojis?: string[];
questionUri?: string;
uri?: string; uri?: string;
app?: IApp; app?: IApp;
}; };

View file

@ -0,0 +1,78 @@
import Vote from '../../../models/poll-vote';
import Note, { INote } from '../../../models/note';
import Watching from '../../../models/note-watching';
import watch from '../../../services/note/watch';
import { publishNoteStream } from '../../../stream';
import notify from '../../../notify';
import createNote from '../../../services/note/create';
import { isLocalUser, IUser } from '../../../models/user';
export default (user: IUser, note: INote, choice: number) => new Promise(async (res, rej) => {
if (!note.poll.choices.some(x => x.id == choice)) return rej('invalid choice param');
// if already voted
const exist = await Vote.findOne({
noteId: note._id,
userId: user._id
});
if (exist !== null) {
return rej('already voted');
}
// Create vote
await Vote.insert({
createdAt: new Date(),
noteId: note._id,
userId: user._id,
choice: choice
});
// Send response
res();
const inc: any = {};
inc[`poll.choices.${note.poll.choices.findIndex(c => c.id == choice)}.votes`] = 1;
// Increment votes count
await Note.update({ _id: note._id }, {
$inc: inc
});
publishNoteStream(note._id, 'pollVoted', {
choice: choice,
userId: user._id.toHexString()
});
// Notify
notify(note.userId, user._id, 'poll_vote', {
noteId: note._id,
choice: choice
});
// Fetch watchers
Watching
.find({
noteId: note._id,
userId: { $ne: user._id },
// 削除されたドキュメントは除く
deletedAt: { $exists: false }
}, {
fields: {
userId: true
}
})
.then(watchers => {
for (const watcher of watchers) {
notify(watcher.userId, user._id, 'poll_vote', {
noteId: note._id,
choice: choice
});
}
});
// ローカルユーザーが投票した場合この投稿をWatchする
if (isLocalUser(user) && user.settings.autoWatch !== false) {
watch(user._id, note);
}
});