リモートで投票を見たりしたりできるように (#3940)
* fix type * expose Question * Note refs Question * rename * wip * リモート投票の場合リプライ送信 * voteの実装をservicesに移動 * 投票受信 * debug * つくる * Revert "つくる" This reverts commit 0c9245886680b7d3b93a0278642f4cf6a43b5cb2. * APIの実装はもどし * Send Update * AP type * Recv Update * Revert "Recv Update" This reverts commit ffda39c0936d8e023f64603edabeb8e0eb9fc370. * Revert "AP type" This reverts commit 63d8bbe29dd6f326773214346350607cc4381996. * Revert "Send Update" This reverts commit 171b046de549f1478e928dee3177eeefab341fcf. * リモートで投票を見る * 投票はDM * Provides choices as text for AP * 絵文字 * fix error * revert * APからには不要な処理を削除 * Revert "APからには不要な処理を削除" This reverts commit 8b5d8af9b0cc4d4ad0cf21de59827ff21df99560. * てぬき * めんどい * ちっ * remove unused code
This commit is contained in:
		
							parent
							
								
									6bbccedb2d
								
							
						
					
					
						commit
						4a57482216
					
				
					 10 changed files with 208 additions and 7 deletions
				
			
		| 
						 | 
				
			
			@ -38,11 +38,7 @@ export type INote = {
 | 
			
		|||
	fileIds: mongo.ObjectID[];
 | 
			
		||||
	replyId: mongo.ObjectID;
 | 
			
		||||
	renoteId: mongo.ObjectID;
 | 
			
		||||
	poll: {
 | 
			
		||||
		choices: Array<{
 | 
			
		||||
			id: number;
 | 
			
		||||
		}>
 | 
			
		||||
	};
 | 
			
		||||
	poll: IPoll;
 | 
			
		||||
	text: string;
 | 
			
		||||
	tags: string[];
 | 
			
		||||
	tagsLower: string[];
 | 
			
		||||
| 
						 | 
				
			
			@ -102,6 +98,16 @@ export type INote = {
 | 
			
		|||
	_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) => {
 | 
			
		||||
	let hide = false;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,8 @@ import Emoji, { IEmoji } from '../../../models/emoji';
 | 
			
		|||
import { ITag } from './tag';
 | 
			
		||||
import { toUnicode } from 'punycode';
 | 
			
		||||
import { unique, concat, difference } from '../../../prelude/array';
 | 
			
		||||
import { extractPollFromQuestion } from './question';
 | 
			
		||||
import vote from '../../../services/note/polls/vote';
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
 | 
			
		||||
	// 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 => {
 | 
			
		||||
		console.log(`extractEmojis: ${e}`);
 | 
			
		||||
		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 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) {
 | 
			
		||||
		updatePerson(note.attributedTo);
 | 
			
		||||
| 
						 | 
				
			
			@ -137,6 +152,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 | 
			
		|||
		apMentions,
 | 
			
		||||
		apHashtags,
 | 
			
		||||
		apEmojis,
 | 
			
		||||
		questionUri,
 | 
			
		||||
		poll,
 | 
			
		||||
		uri: note.id
 | 
			
		||||
	}, silent);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										19
									
								
								src/remote/activitypub/models/question.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/remote/activitypub/models/question.ts
									
										
									
									
									
										Normal 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
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -93,17 +93,27 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 | 
			
		|||
 | 
			
		||||
	let text = note.text;
 | 
			
		||||
 | 
			
		||||
	let question: string;
 | 
			
		||||
	if (note.poll != null) {
 | 
			
		||||
		if (text == null) text = '';
 | 
			
		||||
		const url = `${config.url}/notes/${note._id}`;
 | 
			
		||||
		// TODO: i18n
 | 
			
		||||
		text += `\n\n[投票を見る](${url})`;
 | 
			
		||||
		text += `\n\n[リモートで投票を見る](${url})`;
 | 
			
		||||
 | 
			
		||||
		question = `${config.url}/questions/${note._id}`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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 (apText == null) apText = '';
 | 
			
		||||
		apText += `\n\nRE: ${quote}`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -130,6 +140,7 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 | 
			
		|||
		content,
 | 
			
		||||
		_misskey_content: text,
 | 
			
		||||
		_misskey_quote: quote,
 | 
			
		||||
		_misskey_question: question,
 | 
			
		||||
		published: note.createdAt.toISOString(),
 | 
			
		||||
		to,
 | 
			
		||||
		cc,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										20
									
								
								src/remote/activitypub/renderer/question.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/remote/activitypub/renderer/question.ts
									
										
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +42,7 @@ export interface INote extends IObject {
 | 
			
		|||
	type: 'Note';
 | 
			
		||||
	_misskey_content: string;
 | 
			
		||||
	_misskey_quote: string;
 | 
			
		||||
	_misskey_question: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IPerson extends IObject {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ import Outbox, { packActivity } from './activitypub/outbox';
 | 
			
		|||
import Followers from './activitypub/followers';
 | 
			
		||||
import Following from './activitypub/following';
 | 
			
		||||
import Featured from './activitypub/featured';
 | 
			
		||||
import renderQuestion from '../remote/activitypub/renderer/question';
 | 
			
		||||
 | 
			
		||||
// Init router
 | 
			
		||||
const router = new Router();
 | 
			
		||||
| 
						 | 
				
			
			@ -110,6 +111,36 @@ router.get('/notes/:note/activity', async 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
 | 
			
		||||
router.get('/users/:user/outbox', Outbox);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,8 @@ import watch from '../../../../../services/note/watch';
 | 
			
		|||
import { publishNoteStream } from '../../../../../stream';
 | 
			
		||||
import notify from '../../../../../notify';
 | 
			
		||||
import define from '../../../define';
 | 
			
		||||
import createNote from '../../../../../services/note/create';
 | 
			
		||||
import User from '../../../../../models/user';
 | 
			
		||||
 | 
			
		||||
export const meta = {
 | 
			
		||||
	desc: {
 | 
			
		||||
| 
						 | 
				
			
			@ -114,4 +116,19 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => {
 | 
			
		|||
	if (user.settings.autoWatch !== false) {
 | 
			
		||||
		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 ],
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -103,6 +103,7 @@ type Option = {
 | 
			
		|||
	apMentions?: IUser[];
 | 
			
		||||
	apHashtags?: string[];
 | 
			
		||||
	apEmojis?: string[];
 | 
			
		||||
	questionUri?: string;
 | 
			
		||||
	uri?: string;
 | 
			
		||||
	app?: IApp;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										78
									
								
								src/services/note/polls/vote.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/services/note/polls/vote.ts
									
										
									
									
									
										Normal 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);
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue