diff --git a/.gitignore b/.gitignore index a7b211e..6849230 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ config.json built node_modules memory.json +font.ttf diff --git a/README.md b/README.md index 3109a9e..453881a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ Misskey用の日本語Botです。 ``` `npm install` して `npm run build` して `npm start` すれば起動できます +## フォント +一部の機能にはフォントが必要です。藍にはフォントは同梱されていないので、ご自身でフォントをインストールディレクトリに`font.ttf`という名前で設置してください。 + ## 記憶 藍は記憶の保持にインメモリデータベースを使用しており、藍のインストールディレクトリに `memory.json` という名前で永続化されます。 diff --git a/src/index.ts b/src/index.ts index 78d75b6..1b66e8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import ServerModule from './modules/server'; import FollowModule from './modules/follow'; import ValentineModule from './modules/valentine'; import MazeModule from './modules/maze'; +import ChartModule from './modules/chart'; import chalk from 'chalk'; import * as request from 'request-promise-native'; @@ -71,6 +72,7 @@ promiseRetry(retry => { new ValentineModule(), new KeywordModule(), new MazeModule(), + new ChartModule(), ]); }).catch(e => { log(chalk.red('Failed to fetch the account')); diff --git a/src/modules/chart/index.ts b/src/modules/chart/index.ts new file mode 100644 index 0000000..2d0869a --- /dev/null +++ b/src/modules/chart/index.ts @@ -0,0 +1,107 @@ +import autobind from 'autobind-decorator'; +import Module from '../../module'; +import serifs from '../../serifs'; +import Message from '../../message'; +import { renderChart } from './render-chart'; + +export default class extends Module { + public readonly name = 'chart'; + + @autobind + public install() { + this.post(); + setInterval(this.post, 1000 * 60 * 3); + + return { + mentionHook: this.mentionHook + }; + } + + @autobind + private async post() { + const now = new Date(); + if (now.getHours() !== 23) return; + const date = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`; + const data = this.getData(); + if (data.lastPosted == date) return; + data.lastPosted = date; + this.setData(data); + + this.log('Time to chart'); + const file = await this.genChart('notes'); + + this.log('Posting...'); + this.ai.post({ + text: serifs.chart.post, + fileIds: [file.id] + }); + } + + @autobind + private async genChart(type, params?): Promise { + this.log('Chart data fetching...'); + + let chart; + + if (type === 'userNotes') { + const data = await this.ai.api('charts/user/notes', { + span: 'day', + limit: 30, + userId: params.userId + }); + + chart = { + datasets: [{ + data: data.diffs.normal + }, { + data: data.diffs.reply + }, { + data: data.diffs.renote + }] + }; + } else if (type === 'notes') { + const data = await this.ai.api('charts/notes', { + span: 'day', + limit: 30, + }); + + chart = { + datasets: [{ + data: data.local.diffs.normal + }, { + data: data.local.diffs.reply + }, { + data: data.local.diffs.renote + }] + }; + } + + this.log('Chart rendering...'); + const img = renderChart(chart); + + this.log('Image uploading...'); + const file = await this.ai.upload(img, { + filename: 'chart.png', + contentType: 'image/png' + }); + + return file; + } + + @autobind + private async mentionHook(msg: Message) { + if (msg.includes(['チャート'])) { + this.log('Chart requested'); + const file = await this.genChart('userNotes', { + userId: msg.userId + }); + this.log('Replying...'); + msg.replyWithFile(serifs.chart.foryou, file); + return { + reaction: 'like' + }; + } else { + return false; + } + } +} diff --git a/src/modules/chart/render-chart.ts b/src/modules/chart/render-chart.ts new file mode 100644 index 0000000..c9d1b56 --- /dev/null +++ b/src/modules/chart/render-chart.ts @@ -0,0 +1,201 @@ +import { createCanvas, registerFont } from 'canvas'; + +const width = 1024 + 256; +const height = 512 + 256; +const margin = 128; + +const chartAreaX = margin; +const chartAreaY = margin; +const chartAreaWidth = width - (margin * 2); +const chartAreaHeight = height - (margin * 2); + +const lineWidth = 16; +const yAxisThickness = 2; + +const colors = { + bg: '#434343', + text: '#e0e4cc', + yAxis: '#5a5a5a', + dataset: [ + '#ff4e50', + '#c2f725', + '#69d2e7', + '#f38630', + '#f9d423', + ] +}; + +const yAxisTicks = 4; + +type Chart = { + title?: string; + datasets: { + title?: string; + data: number[]; + }[]; +}; + +export function renderChart(chart: Chart) { + registerFont('./font.ttf', { family: 'CustomFont' }); + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + ctx.antialias = 'default'; + + ctx.fillStyle = colors.bg; + ctx.beginPath(); + ctx.fillRect(0, 0, width, height); + + const xAxisCount = chart.datasets[0].data.length; + const serieses = chart.datasets.length; + + let lowerBound = Infinity; + let upperBound = -Infinity; + + for (let xAxis = 0; xAxis < xAxisCount; xAxis++) { + let v = 0; + for (let series = 0; series < serieses; series++) { + v += chart.datasets[series].data[xAxis]; + } + if (v > upperBound) upperBound = v; + if (v < lowerBound) lowerBound = v; + } + + // Calculate Y axis scale + const yAxisSteps = niceScale(lowerBound, upperBound, yAxisTicks); + const yAxisStepsMin = yAxisSteps[0]; + const yAxisStepsMax = yAxisSteps[yAxisSteps.length - 1]; + const yAxisRange = yAxisStepsMax - yAxisStepsMin; + + // Draw Y axis + ctx.lineWidth = yAxisThickness; + ctx.lineCap = 'round'; + ctx.strokeStyle = colors.yAxis; + for (let i = 0; i < yAxisSteps.length; i++) { + const step = yAxisSteps[yAxisSteps.length - i - 1]; + const y = i * (chartAreaHeight / (yAxisSteps.length - 1)); + ctx.beginPath(); + ctx.lineTo(chartAreaX, chartAreaY + y); + ctx.lineTo(chartAreaX + chartAreaWidth, chartAreaY + y); + ctx.stroke(); + + ctx.font = '20px CustomFont'; + ctx.fillStyle = colors.text; + ctx.fillText(step.toString(), chartAreaX, chartAreaY + y - 8); + } + + const newDatasets = []; + + for (let series = 0; series < serieses; series++) { + newDatasets.push({ + data: [] + }); + } + + for (let xAxis = 0; xAxis < xAxisCount; xAxis++) { + for (let series = 0; series < serieses; series++) { + newDatasets[series].data.push(chart.datasets[series].data[xAxis] / yAxisRange); + } + } + + const perXAxisWidth = chartAreaWidth / xAxisCount; + + let newUpperBound = -Infinity; + + for (let xAxis = 0; xAxis < xAxisCount; xAxis++) { + let v = 0; + for (let series = 0; series < serieses; series++) { + v += newDatasets[series].data[xAxis]; + } + if (v > newUpperBound) newUpperBound = v; + } + + // Draw X axis + ctx.lineWidth = lineWidth; + ctx.lineCap = 'round'; + + for (let xAxis = 0; xAxis < xAxisCount; xAxis++) { + const xAxisPerTypeHeights = []; + + for (let series = 0; series < serieses; series++) { + const v = newDatasets[series].data[xAxis]; + const vHeight = (v / newUpperBound) * (chartAreaHeight - ((yAxisStepsMax - upperBound) / yAxisStepsMax * chartAreaHeight)); + xAxisPerTypeHeights.push(vHeight); + } + + for (let series = serieses - 1; series >= 0; series--) { + ctx.strokeStyle = colors.dataset[series % colors.dataset.length]; + + let total = 0; + for (let i = 0; i < series; i++) { + total += xAxisPerTypeHeights[i]; + } + + const height = xAxisPerTypeHeights[series]; + + const x = chartAreaX + (perXAxisWidth * ((xAxisCount - 1) - xAxis)) + (perXAxisWidth / 2); + + const yTop = (chartAreaY + chartAreaHeight) - (total + height); + const yBottom = (chartAreaY + chartAreaHeight) - (total); + + ctx.globalAlpha = 1 - (xAxis / xAxisCount); + ctx.beginPath(); + ctx.lineTo(x, yTop); + ctx.lineTo(x, yBottom); + ctx.stroke(); + } + } + + return canvas.toBuffer(); +} + +// https://stackoverflow.com/questions/326679/choosing-an-attractive-linear-scale-for-a-graphs-y-axis +// https://github.com/apexcharts/apexcharts.js/blob/master/src/modules/Scales.js +// This routine creates the Y axis values for a graph. +function niceScale(lowerBound: number, upperBound: number, ticks: number): number[] { + // Calculate Min amd Max graphical labels and graph + // increments. The number of ticks defaults to + // 10 which is the SUGGESTED value. Any tick value + // entered is used as a suggested value which is + // adjusted to be a 'pretty' value. + // + // Output will be an array of the Y axis values that + // encompass the Y values. + const steps = []; + + // Determine Range + const range = upperBound - lowerBound; + + let tiks = ticks + 1; + // Adjust ticks if needed + if (tiks < 2) { + tiks = 2; + } else if (tiks > 2) { + tiks -= 2; + } + + // Get raw step value + const tempStep = range / tiks; + + // Calculate pretty step value + const mag = Math.floor(Math.log10(tempStep)); + const magPow = Math.pow(10, mag); + const magMsd = (parseInt as any)(tempStep / magPow); + const stepSize = magMsd * magPow; + + // build Y label array. + // Lower and upper bounds calculations + const lb = stepSize * Math.floor(lowerBound / stepSize); + const ub = stepSize * Math.ceil(upperBound / stepSize); + // Build array + let val = lb; + while (1) { + steps.push(val); + val += stepSize; + if (val > ub) { + break; + } + } + + return steps; +} diff --git a/src/serifs.ts b/src/serifs.ts index b430724..b0c083e 100644 --- a/src/serifs.ts +++ b/src/serifs.ts @@ -316,6 +316,11 @@ export default { post: '今日の迷路です! #AiMaze', foryou: '描きました!' }, + + chart: { + post: 'インスタンスの投稿数です!', + foryou: '描きました!' + }, }; export function getSerif(variant: string | string[]): string { diff --git a/torisetu.md b/torisetu.md index 5f7d2c6..e09e06f 100644 --- a/torisetu.md +++ b/torisetu.md @@ -59,6 +59,9 @@ Misskeyにアカウントを作成して初めて投稿を行うと、藍がそ ### バレンタイン 藍がチョコレートをくれます。 +### チャート +インスタンスの投稿チャートなどを投稿してくれます。 + ### サーバー監視 サーバーの状態を監視し、負荷が高くなっているときは教えてくれます。