diff --git a/CHANGELOG.md b/CHANGELOG.md index c45400f88..ba307ece9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ChangeLog (Release Notes) ========================= 主に notable な changes を書いていきます +2735 (2017/10/22) +----------------- +* モバイル版からでもクライアントバージョンを確認できるように + +2732 (2017/10/22) +----------------- +* 依存関係の更新など + 2584 (2017/09/08) ----------------- * New: ユーザーページによく使うドメインを表示 (#771) diff --git a/README.md b/README.md index 2e05298e1..b777618f4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ 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 ---------------------------------------------------------------- @@ -42,14 +43,6 @@ If you want to donate to Misskey, please get in touch with [@syuilo][syuilo-link **Note:** When you donate to Misskey, your name will be displayed in [donors](./DONORS.md). -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) - Copyright ---------------------------------------------------------------- Misskey is an open-source software licensed under [The MIT License](LICENSE). @@ -67,7 +60,3 @@ Misskey is an open-source software licensed under [The MIT License](LICENSE). [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 diff --git a/locales/en.yml b/locales/en.yml index 1df3001e5..d4dfbf76b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -64,7 +64,7 @@ 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." mk-forkit: diff --git a/locales/ja.yml b/locales/ja.yml index 451650ef7..9a8490dec 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -64,7 +64,7 @@ common: mk-error: title: "サーバーに接続できません" - description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。" + description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。" thanks: "いつもMisskeyをご利用いただきありがとうございます。" mk-forkit: diff --git a/package.json b/package.json index 2fb0987b2..4ddb3cb45 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo ", - "version": "0.0.2584", + "version": "0.0.2735", "license": "MIT", "description": "A miniblog-based SNS", "bugs": "https://github.com/syuilo/misskey/issues", @@ -48,30 +48,31 @@ "@types/is-url": "1.2.28", "@types/js-yaml": "3.9.0", "@types/mocha": "2.2.43", - "@types/mongodb": "2.2.11", + "@types/mongodb": "2.2.13", "@types/monk": "1.0.6", "@types/morgan": "1.7.33", "@types/ms": "0.7.30", "@types/multer": "1.3.2", - "@types/node": "8.0.31", + "@types/node": "8.0.33", "@types/ratelimiter": "2.1.28", "@types/redis": "2.6.0", - "@types/request": "2.0.3", + "@types/request": "2.0.4", "@types/rimraf": "2.0.0", "@types/riot": "3.6.0", "@types/serve-favicon": "2.2.28", "@types/uuid": "3.4.2", - "@types/webpack": "3.0.12", + "@types/webpack": "3.0.13", "@types/webpack-stream": "3.2.7", "@types/websocket": "0.0.34", + "awesome-typescript-loader": "3.2.3", "chai": "4.1.2", "chai-http": "3.0.0", "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", @@ -83,15 +84,15 @@ "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.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.6.0" + "uglifyjs-webpack-plugin": "1.0.0-beta.2", + "webpack": "3.8.1" }, "dependencies": { "accesses": "2.5.0", @@ -103,12 +104,12 @@ "chalk": "2.1.0", "compression": "1.7.1", "cors": "2.8.4", - "cropperjs": "1.0.0", + "cropperjs": "1.1.3", "crypto": "1.0.1", "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", @@ -122,8 +123,8 @@ "js-yaml": "3.10.0", "mecab-async": "^0.1.0", "moji": "^0.5.1", - "mongodb": "2.2.31", - "monk": "6.0.4", + "mongodb": "2.2.33", + "monk": "6.0.5", "morgan": "1.9.0", "ms": "2.0.0", "multer": "1.3.0", @@ -139,7 +140,7 @@ "redis": "2.8.0", "request": "2.83.0", "rimraf": "2.6.2", - "riot": "3.7.2", + "riot": "3.7.3", "rndstr": "1.0.0", "s-age": "1.1.0", "serve-favicon": "2.4.5", @@ -151,7 +152,7 @@ "typescript": "2.5.3", "uuid": "3.1.0", "vhost": "3.0.2", - "websocket": "1.0.24", + "websocket": "1.0.25", "xev": "2.0.0" } } diff --git a/src/api/bot/core.ts b/src/api/bot/core.ts new file mode 100644 index 000000000..53fb18119 --- /dev/null +++ b/src/api/bot/core.ts @@ -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 { + 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 { + 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 { + 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; + public abstract async q(query: string): Promise; + 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 { + return 'まずユーザー名を教えてください:'; + } + + public async q(query: string): Promise { + 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 { + return '内容:'; + } + + public async q(query: string): Promise { + 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 { + this.secret = Math.floor(Math.random() * 100); + this.emit('updated'); + return '0~100の秘密の数を当ててみてください:'; + } + + public async q(query: string): Promise { + 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 { + return this.othello.toPatternString('black'); + } + + public async q(query: string): Promise { + 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; + } +} diff --git a/src/api/bot/interfaces/line.ts b/src/api/bot/interfaces/line.ts new file mode 100644 index 000000000..0caa71ed2 --- /dev/null +++ b/src/api/bot/interfaces/line.ts @@ -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 { + 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); + } + }); +}; diff --git a/src/api/endpoints/i/appdata/set.ts b/src/api/endpoints/i/appdata/set.ts index 24f192de6..9c3dbe185 100644 --- a/src/api/endpoints/i/appdata/set.ts +++ b/src/api/endpoints/i/appdata/set.ts @@ -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'); diff --git a/src/api/models/user.ts b/src/api/models/user.ts index 1591b339b..b2f3af09f 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -57,6 +57,9 @@ export type IUser = { user_id: string; screen_name: string; }; + line: { + user_id: string; + }; description: string; profile: { location: string; @@ -70,3 +73,11 @@ export type IUser = { 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; +} diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts index 23a176096..3deff2d00 100644 --- a/src/api/serializers/user.ts +++ b/src/api/serializers/user.ts @@ -79,6 +79,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) { diff --git a/src/api/server.ts b/src/api/server.ts index c98167eb3..3de32d9ea 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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; diff --git a/src/web/app/common/scripts/get-post-summary.js b/src/common/get-post-summary.ts similarity index 83% rename from src/web/app/common/scripts/get-post-summary.js rename to src/common/get-post-summary.ts index 83eda8f6b..f628a32b4 100644 --- a/src/web/app/common/scripts/get-post-summary.js +++ b/src/common/get-post-summary.ts @@ -1,4 +1,8 @@ -const summarize = post => { +/** + * 投稿を表す文字列を取得します。 + * @param {*} post 投稿 + */ +const summarize = (post: any): string => { let summary = post.text ? post.text : ''; // メディアが添付されているとき diff --git a/src/common/get-user-summary.ts b/src/common/get-user-summary.ts new file mode 100644 index 000000000..1bec2f9a2 --- /dev/null +++ b/src/common/get-user-summary.ts @@ -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}」`; +} diff --git a/src/common/othello.ts b/src/common/othello.ts new file mode 100644 index 000000000..858fc3315 --- /dev/null +++ b/src/common/othello.ts @@ -0,0 +1,268 @@ +const BOARD_SIZE = 8; + +export default class Othello { + public board: Array>; + + /** + * ゲームを初期化します + */ + 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(); +} diff --git a/src/config.ts b/src/config.ts index f8facdee2..46a93f5fe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,10 @@ type Source = { hook_secret: string; username: string; }; + line_bot?: { + channel_secret: string; + channel_access_token: string; + }; analysis?: { mecab_command?: string; }; diff --git a/src/tsconfig.json b/src/tsconfig.json index ecff047a7..36600eed2 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "noEmitOnError": false, "noImplicitAny": false, "noImplicitReturns": true, diff --git a/src/web/app/common/tags/error.tag b/src/web/app/common/tags/error.tag index e4e0272a4..7a2976541 100644 --- a/src/web/app/common/tags/error.tag +++ b/src/web/app/common/tags/error.tag @@ -1,7 +1,13 @@ - +

%i18n:common.tags.mk-error.title%

-

%i18n:common.tags.mk-error.description%

+

{ + '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{')) + }{ + '%i18n:common.tags.mk-error.description%'.match(/\{(.+?)\}/)[1] + }{ + '%i18n:common.tags.mk-error.description%'.substr('%i18n:common.tags.mk-error.description%'.indexOf('}') + 1) + }

%i18n:common.tags.mk-error.thanks%

diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag index 366370952..53222b9db 100644 --- a/src/web/app/mobile/tags/notification.tag +++ b/src/web/app/mobile/tags/notification.tag @@ -163,7 +163,7 @@ diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag index 2f314769d..7370aa84d 100644 --- a/src/web/app/mobile/tags/notifications.tag +++ b/src/web/app/mobile/tags/notifications.tag @@ -78,7 +78,7 @@ diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag index dc032fe96..ed275749e 100644 --- a/src/web/app/mobile/tags/post-detail.tag +++ b/src/web/app/mobile/tags/post-detail.tag @@ -264,7 +264,7 @@