├── external-scripts.json ├── .gitignore ├── bin ├── hubot.cmd └── hubot ├── .editorconfig ├── src ├── lib │ ├── types.d.ts │ ├── git_repository_updater.ts │ ├── reading_vimrc_progressor.ts │ └── reading_vimrc_repos.ts └── scripts │ └── reading-vimrc.ts ├── tsconfig.json ├── .eslintrc.yml ├── package.json └── README.md /external-scripts.json: -------------------------------------------------------------------------------- 1 | [ 2 | "hubot-vimhelp" 3 | ] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store* 3 | .hubot_history 4 | 5 | /lib/ 6 | /scripts/ 7 | -------------------------------------------------------------------------------- /bin/hubot.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | npm install && node_modules\.bin\hubot.cmd --name "vim-jp-bot" %* -------------------------------------------------------------------------------- /bin/hubot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | npm install 6 | export PATH="node_modules/.bin:node_modules/hubot/node_modules/.bin:$PATH" 7 | 8 | exec node_modules/.bin/hubot --name "vim-jp-bot" "$@" 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /src/lib/types.d.ts: -------------------------------------------------------------------------------- 1 | export interface VimrcFile { 2 | url: string; 3 | raw_url?: string; 4 | name: string; 5 | hash: string | null; 6 | } 7 | 8 | export interface NextVimrc { 9 | id: number; 10 | date: string; 11 | author: { 12 | name: string; 13 | url: string; 14 | }; 15 | vimrcs: VimrcFile[]; 16 | part: string | null; 17 | other: string | null; 18 | } 19 | 20 | export interface ArchiveVimrc extends NextVimrc { 21 | members?: string[]; 22 | log?: string; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "preserveConstEnums": true, 8 | "noImplicitAny": true, 9 | "noImplicitReturns": true, 10 | "noImplicitThis": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noEmitOnError": true, 14 | "strictNullChecks": true, 15 | "target": "es2020", 16 | "esModuleInterop": true, 17 | "resolveJsonModule": true, 18 | "downlevelIteration": true 19 | }, 20 | "exclude": [], 21 | "include": [ 22 | "src/**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | es6: true 4 | node: true 5 | extends: 6 | - 'eslint:recommended' 7 | - 'plugin:@typescript-eslint/eslint-recommended' 8 | - 'plugin:@typescript-eslint/recommended' 9 | globals: 10 | Atomics: readonly 11 | SharedArrayBuffer: readonly 12 | parser: '@typescript-eslint/parser' 13 | parserOptions: 14 | ecmaVersion: 2018 15 | sourceType: module 16 | plugins: 17 | - '@typescript-eslint' 18 | rules: 19 | indent: 20 | - error 21 | - 2 22 | - SwitchCase: 1 23 | linebreak-style: 24 | - error 25 | - unix 26 | quotes: 27 | - error 28 | - double 29 | semi: 30 | - error 31 | - always 32 | '@typescript-eslint/no-unused-vars': 33 | - error 34 | - argsIgnorePattern: '^_' 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vim-jp-bot", 3 | "description": "A helphul robot for vim-jp", 4 | "version": "0.0.0", 5 | "author": "thinca ", 6 | "dependencies": { 7 | "@octokit/rest": "^20.0.1", 8 | "hubot": "<4.0.0", 9 | "hubot-kokoro.io": "^2.2.0", 10 | "hubot-matrix": "github:thinca/hubot-matrix#set-displayName-to-user-in-message", 11 | "hubot-slack": "^4.10.0", 12 | "hubot-vimhelp": "^5.0.0", 13 | "js-yaml": "^4.1.0", 14 | "printf": "^0.6.1" 15 | }, 16 | "devDependencies": { 17 | "@types/hubot": "^3.3.2", 18 | "@types/js-yaml": "^4.0.5", 19 | "@types/node": "^20.4.2", 20 | "@types/node-fetch": "^2.6.4", 21 | "@typescript-eslint/eslint-plugin": "^6.1.0", 22 | "@typescript-eslint/parser": "^6.1.0", 23 | "coffeescript": "^1.12.7", 24 | "eslint": "^8.45.0", 25 | "eslint-plugin-import": "^2.27.5", 26 | "ts-node": "^10.9.1", 27 | "typescript": "^5.1.6" 28 | }, 29 | "engines": { 30 | "node": ">=6.0.0" 31 | }, 32 | "license": "Zlib", 33 | "private": true, 34 | "scripts": { 35 | "build": "tsc", 36 | "lint": "eslint src" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/git_repository_updater.ts: -------------------------------------------------------------------------------- 1 | import {spawn, SpawnOptionsWithoutStdio} from "child_process"; 2 | import * as fs from "fs"; 3 | 4 | export class GitRepositoryUpdater { 5 | reposURL: string; 6 | workDir: string; 7 | branch: string | undefined; 8 | 9 | constructor(reposURL: string, workDir: string, opts: {branch?: string}) { 10 | this.reposURL = reposURL; 11 | this.workDir = workDir; 12 | opts = opts || {}; 13 | this.branch = opts.branch; 14 | } 15 | 16 | async setup(): Promise { 17 | const result = await this.setupWorkDir(); 18 | if (!result.workDirCreated) { 19 | return await this.updateReposToLatest(); 20 | } 21 | } 22 | 23 | async setupWorkDir(): Promise<{workDirCreated: boolean}> { 24 | if (fs.existsSync(this.workDir)) { 25 | return {workDirCreated: false}; 26 | } 27 | const args = ["clone", this.reposURL, this.workDir]; 28 | if (this.branch) { 29 | args.push("--branch", this.branch); 30 | } 31 | 32 | await this._execGit(args, true); 33 | return {workDirCreated: true}; 34 | } 35 | 36 | async commitAndPush(message: string): Promise { 37 | const git = this._execGit.bind(this); 38 | await git(["add", "."]); 39 | await git(["commit", "--message", message]); 40 | const branch = this.branch || "HEAD"; 41 | await git(["push", "origin", branch]); 42 | return message; 43 | } 44 | 45 | async updateReposToLatest(): Promise { 46 | const git = this._execGit.bind(this); 47 | await git(["fetch"]); 48 | const branch = this.branch || "master"; 49 | await git(["reset", "--hard", `origin/${branch}`]); 50 | } 51 | 52 | _execGit(args: string[], clone = false): Promise { 53 | return new Promise((resolve, reject) => { 54 | const opts: SpawnOptionsWithoutStdio = { 55 | env: Object.assign({}, process.env), 56 | }; 57 | if (!clone) { 58 | opts.cwd = this.workDir; 59 | } 60 | const proc = spawn("git", args, opts); 61 | let stdoutString = ""; 62 | proc.stdout.on("data", (chunk) => { 63 | stdoutString += chunk; 64 | }); 65 | let stderrString = ""; 66 | proc.stderr.on("data", (chunk) => { 67 | stderrString += chunk; 68 | }); 69 | proc.on("exit", (code, signal) => { 70 | if (code) { 71 | reject({ 72 | command: "git", 73 | args: args, 74 | code: code, 75 | signal: signal, 76 | stdout: stdoutString, 77 | stderr: stderrString, 78 | }); 79 | } else { 80 | resolve(true); 81 | } 82 | }); 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/reading_vimrc_progressor.ts: -------------------------------------------------------------------------------- 1 | import {Message} from "hubot"; 2 | import {VimrcFile} from "./types"; 3 | 4 | export class ReadingVimrcProgressor { 5 | id: number; 6 | logURL: string; 7 | messages: Message[]; 8 | restore_cache: Message[]; 9 | vimrcs: VimrcFile[]; 10 | isRunning: boolean; 11 | vimrcContents: Map; 12 | vimrcLastname: Map; 13 | part: string | null; 14 | 15 | constructor() { 16 | this.id = 0; 17 | this.logURL = ""; 18 | this.messages = []; 19 | this.restore_cache = []; 20 | this.vimrcs = []; 21 | this.isRunning = false; 22 | this.vimrcContents = new Map(); 23 | this.vimrcLastname = new Map(); 24 | this.part = null; 25 | } 26 | 27 | get status(): "started" | "stopped" { 28 | return this.isRunning ? "started" : "stopped"; 29 | } 30 | 31 | get members(): string[] { 32 | return [...new Set(this.messages.map((mes) => mes.user.name)).values()].filter((m) => m); 33 | } 34 | 35 | start(id: number, logURL: string, vimrcs: VimrcFile[], part: string | null): void { 36 | this.id = id; 37 | this.logURL = logURL; 38 | this.vimrcs = vimrcs; 39 | this.messages = []; 40 | this.isRunning = true; 41 | this.part = part; 42 | this.clearVimrcs(); 43 | } 44 | 45 | stop(): void { 46 | this.isRunning = false; 47 | } 48 | 49 | reset(): void { 50 | this.restore_cache = this.messages; 51 | this.messages = []; 52 | } 53 | 54 | restore(): void { 55 | [this.restore_cache, this.messages] = [this.messages, this.restore_cache]; 56 | } 57 | 58 | addMessage(message: Message): void { 59 | if (!this.isRunning) { 60 | return; 61 | } 62 | this.messages.push(message); 63 | } 64 | 65 | setVimrcContent(name: string, content: string): void { 66 | this.vimrcContents.set(name, content.split(/\r?\n/)); 67 | } 68 | 69 | getVimrcFile(namePat: string, username: string): [string, string[]] | [] { 70 | const names = [...this.vimrcContents.keys()]; 71 | let name: string | undefined; 72 | if (namePat) { 73 | const patternCandidates = [ 74 | `^${namePat}$`, 75 | `/${namePat}$`, 76 | `/${namePat}(?:\\..*)?$`, 77 | namePat, 78 | ]; 79 | for (const pat of patternCandidates) { 80 | const reg = new RegExp(pat, "i"); 81 | name = names.find((n) => reg.test(n)); 82 | if (name) { 83 | break; 84 | } 85 | } 86 | } else if (this.vimrcLastname.has(username)) { 87 | name = this.vimrcLastname.get(username); 88 | } else { 89 | name = names[0]; 90 | } 91 | if (name == null || !this.vimrcContents.has(name)) { 92 | return []; 93 | } 94 | if (username) { 95 | this.vimrcLastname.set(username, name); 96 | } 97 | return [name, this.vimrcContents.get(name) || []]; 98 | } 99 | 100 | getVimrcLines(content: string[], startLine: number, endLine = startLine): string[] { 101 | return content.slice(startLine - 1, endLine); 102 | } 103 | 104 | clearVimrcs(): void { 105 | this.vimrcContents.clear(); 106 | this.vimrcLastname.clear(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vim-jp-bot 2 | 3 | vim-jp-bot is a chat bot built on the [Hubot][hubot] framework. It was 4 | initially generated by [generator-hubot][generator-hubot], and configured to be 5 | deployed on [Heroku][heroku] to get you up and running as quick as possible. 6 | 7 | This README is intended to help get you started. Definitely update and improve 8 | to talk about your own instance, how to use and deploy, what functionality he 9 | has, etc! 10 | 11 | [heroku]: http://www.heroku.com 12 | [hubot]: http://hubot.github.com 13 | [generator-hubot]: https://github.com/github/generator-hubot 14 | 15 | ### Running vim-jp-bot Locally 16 | 17 | You can test your hubot by running the following, however some plugins will not 18 | behave as expected unless the [environment variables](#configuration) they rely 19 | upon have been set. 20 | 21 | You can start vim-jp-bot locally by running: 22 | 23 | % bin/hubot 24 | 25 | You'll see some start up output and a prompt: 26 | 27 | [Sat Feb 28 2015 12:38:27 GMT+0000 (GMT)] INFO Using default redis on localhost:6379 28 | vim-jp-bot> 29 | 30 | Then you can interact with vim-jp-bot by typing `vim-jp-bot help`. 31 | 32 | vim-jp-bot> vim-jp-bot help 33 | vim-jp-bot animate me - The same thing as `image me`, except adds [snip] 34 | vim-jp-bot help - Displays all of the help commands that vim-jp-bot knows about. 35 | ... 36 | 37 | ### Configuration 38 | 39 | A few scripts (including some installed by default) require environment 40 | variables to be set as a simple form of configuration. 41 | 42 | Each script should have a commented header which contains a "Configuration" 43 | section that explains which values it requires to be placed in which variable. 44 | When you have lots of scripts installed this process can be quite labour 45 | intensive. The following shell command can be used as a stop gap until an 46 | easier way to do this has been implemented. 47 | 48 | grep -o 'hubot-[a-z0-9_-]\+' external-scripts.json | \ 49 | xargs -n1 -I {} sh -c 'sed -n "/^# Configuration/,/^#$/ s/^/{} /p" \ 50 | $(find node_modules/{}/ -name "*.coffee")' | \ 51 | awk -F '#' '{ printf "%-25s %s\n", $1, $2 }' 52 | 53 | How to set environment variables will be specific to your operating system. 54 | Rather than recreate the various methods and best practices in achieving this, 55 | it's suggested that you search for a dedicated guide focused on your OS. 56 | 57 | ### Scripting 58 | 59 | An example script is included at `scripts/example.coffee`, so check it out to 60 | get started, along with the [Scripting Guide](scripting-docs). 61 | 62 | For many common tasks, there's a good chance someone has already one to do just 63 | the thing. 64 | 65 | [scripting-docs]: https://github.com/github/hubot/blob/master/docs/scripting.md 66 | 67 | ### external-scripts 68 | 69 | There will inevitably be functionality that everyone will want. Instead of 70 | writing it yourself, you can use existing plugins. 71 | 72 | Hubot is able to load plugins from third-party `npm` packages. This is the 73 | recommended way to add functionality to your hubot. You can get a list of 74 | available hubot plugins on [npmjs.com](npmjs) or by using `npm search`: 75 | 76 | % npm search hubot-scripts panda 77 | NAME DESCRIPTION AUTHOR DATE VERSION KEYWORDS 78 | hubot-pandapanda a hubot script for panda responses =missu 2014-11-30 0.9.2 hubot hubot-scripts panda 79 | ... 80 | 81 | 82 | To use a package, check the package's documentation, but in general it is: 83 | 84 | 1. Use `npm install --save` to add the package to `package.json` and install it 85 | 2. Add the package name to `external-scripts.json` as a double quoted string 86 | 87 | You can review `external-scripts.json` to see what is included by default. 88 | 89 | ##### Advanced Usage 90 | 91 | It is also possible to define `external-scripts.json` as an object to 92 | explicitly specify which scripts from a package should be included. The example 93 | below, for example, will only activate two of the six available scripts inside 94 | the `hubot-fun` plugin, but all four of those in `hubot-auto-deploy`. 95 | 96 | ```json 97 | { 98 | "hubot-fun": [ 99 | "crazy", 100 | "thanks" 101 | ], 102 | "hubot-auto-deploy": "*" 103 | } 104 | ``` 105 | 106 | **Be aware that not all plugins support this usage and will typically fallback 107 | to including all scripts.** 108 | 109 | [npmjs]: https://www.npmjs.com 110 | 111 | ### hubot-scripts 112 | 113 | Before hubot plugin packages were adopted, most plugins were held in the 114 | [hubot-scripts][hubot-scripts] package. Some of these plugins have yet to be 115 | migrated to their own packages. They can still be used but the setup is a bit 116 | different. 117 | 118 | To enable scripts from the hubot-scripts package, add the script name with 119 | extension as a double quoted string to the `hubot-scripts.json` file in this 120 | repo. 121 | 122 | [hubot-scripts]: https://github.com/github/hubot-scripts 123 | 124 | ## Persistence 125 | 126 | If you are going to use the `hubot-redis-brain` package (strongly suggested), 127 | you will need to add the Redis to Go addon on Heroku which requires a verified 128 | account or you can create an account at [Redis to Go][redistogo] and manually 129 | set the `REDISTOGO_URL` variable. 130 | 131 | % heroku config:add REDISTOGO_URL="..." 132 | 133 | If you don't need any persistence feel free to remove the `hubot-redis-brain` 134 | from `external-scripts.json` and you don't need to worry about redis at all. 135 | 136 | [redistogo]: https://redistogo.com/ 137 | 138 | ## Adapters 139 | 140 | Adapters are the interface to the service you want your hubot to run on, such 141 | as Campfire or IRC. There are a number of third party adapters that the 142 | community have contributed. Check [Hubot Adapters][hubot-adapters] for the 143 | available ones. 144 | 145 | If you would like to run a non-Campfire or shell adapter you will need to add 146 | the adapter package as a dependency to the `package.json` file in the 147 | `dependencies` section. 148 | 149 | Once you've added the dependency with `npm install --save` to install it you 150 | can then run hubot with the adapter. 151 | 152 | % bin/hubot -a 153 | 154 | Where `` is the name of your adapter without the `hubot-` prefix. 155 | 156 | [hubot-adapters]: https://github.com/github/hubot/blob/master/docs/adapters.md 157 | 158 | ## Deployment 159 | 160 | % heroku create --stack cedar 161 | % git push heroku master 162 | 163 | If your Heroku account has been verified you can run the following to enable 164 | and add the Redis to Go addon to your app. 165 | 166 | % heroku addons:add redistogo:nano 167 | 168 | If you run into any problems, checkout Heroku's [docs][heroku-node-docs]. 169 | 170 | You'll need to edit the `Procfile` to set the name of your hubot. 171 | 172 | More detailed documentation can be found on the [deploying hubot onto 173 | Heroku][deploy-heroku] wiki page. 174 | 175 | ### Deploying to UNIX or Windows 176 | 177 | If you would like to deploy to either a UNIX operating system or Windows. 178 | Please check out the [deploying hubot onto UNIX][deploy-unix] and [deploying 179 | hubot onto Windows][deploy-windows] wiki pages. 180 | 181 | [heroku-node-docs]: http://devcenter.heroku.com/articles/node-js 182 | [deploy-heroku]: https://github.com/github/hubot/blob/master/docs/deploying/heroku.md 183 | [deploy-unix]: https://github.com/github/hubot/blob/master/docs/deploying/unix.md 184 | [deploy-windows]: https://github.com/github/hubot/blob/master/docs/deploying/unix.md 185 | 186 | ## Campfire Variables 187 | 188 | If you are using the Campfire adapter you will need to set some environment 189 | variables. If not, refer to your adapter documentation for how to configure it, 190 | links to the adapters can be found on [Hubot Adapters][hubot-adapters]. 191 | 192 | Create a separate Campfire user for your bot and get their token from the web 193 | UI. 194 | 195 | % heroku config:add HUBOT_CAMPFIRE_TOKEN="..." 196 | 197 | Get the numeric IDs of the rooms you want the bot to join, comma delimited. If 198 | you want the bot to connect to `https://mysubdomain.campfirenow.com/room/42` 199 | and `https://mysubdomain.campfirenow.com/room/1024` then you'd add it like 200 | this: 201 | 202 | % heroku config:add HUBOT_CAMPFIRE_ROOMS="42,1024" 203 | 204 | Add the subdomain hubot should connect to. If you web URL looks like 205 | `http://mysubdomain.campfirenow.com` then you'd add it like this: 206 | 207 | % heroku config:add HUBOT_CAMPFIRE_ACCOUNT="mysubdomain" 208 | 209 | [hubot-adapters]: https://github.com/github/hubot/blob/master/docs/adapters.md 210 | 211 | ## Restart the bot 212 | 213 | You may want to get comfortable with `heroku logs` and `heroku restart` if 214 | you're having issues. 215 | -------------------------------------------------------------------------------- /src/lib/reading_vimrc_repos.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import {URL} from "url"; 3 | import * as fs from "fs/promises"; 4 | import * as YAML from "js-yaml"; 5 | import fetch from "node-fetch"; 6 | import printf from "printf"; 7 | import {ArchiveVimrc, NextVimrc, VimrcFile} from "./types"; 8 | import {GitRepositoryUpdater} from "./git_repository_updater"; 9 | 10 | const TEMPLATE_TEXT = `--- 11 | layout: archive 12 | title: 第%d回 vimrc読書会 13 | id: %d 14 | category: archive 15 | --- 16 | {%% include archive.md %%} 17 | `; 18 | 19 | const nextWeek = (dateString: string): string => { 20 | const [year, month, day] = dateString.split(/\D+/).map((n) => Number.parseInt(n, 10)); 21 | const date = new Date(Date.UTC(year, month - 1, day)); 22 | date.setDate(date.getDate() + 7); 23 | return printf( 24 | "%04d-%02d-%02d 23:00", 25 | date.getUTCFullYear(), 26 | date.getUTCMonth() + 1, 27 | date.getUTCDate(), 28 | ); 29 | }; 30 | 31 | const makeRawURL = (urlString: string): string => { 32 | const url = new URL(urlString); 33 | url.hostname = "raw.githubusercontent.com"; 34 | const pathnames = url.pathname.split("/"); 35 | pathnames.splice(3, 1); 36 | url.pathname = pathnames.join("/"); 37 | return url.toString(); 38 | }; 39 | 40 | const makeWikiRequestLine = (author: string, requester: string, urlString: string, lineNum: number, comment?: string) => { 41 | return `${author} | ${lineNum} | ${requester} | ${comment || ""} | [リンク](${urlString})`; 42 | }; 43 | 44 | const makeGithubURLInfo = (url: string): {vimrc: VimrcFile, author: {name: string, url: string}} => { 45 | const paths = url.split("/"); 46 | // XXX: Assume GitHub URL 47 | const hash = /^[0-9a-f]{40}$/.test(paths[6]) ? paths[6] : null; 48 | return { 49 | vimrc: { 50 | url, 51 | hash, 52 | name: paths[paths.length - 1], 53 | }, 54 | author: { 55 | name: paths[3], 56 | url: paths.slice(0, 4).join("/"), 57 | }, 58 | }; 59 | }; 60 | 61 | const extractCommonPath = (urls: string[]): string => { 62 | const firstPathParts = urls[0].split("/"); 63 | const lastPathParts = urls.at(-1)?.split("/"); 64 | if (lastPathParts == null) { 65 | return ""; 66 | } 67 | const commonPathParts = []; 68 | for (let i = 0; i < firstPathParts.length; i++) { 69 | if (firstPathParts[i] === lastPathParts[i]) { 70 | commonPathParts.push(firstPathParts[i]); 71 | } else { 72 | // Append tailing "/" 73 | commonPathParts.push(""); 74 | break; 75 | } 76 | } 77 | return commonPathParts.join("/"); 78 | }; 79 | 80 | const refineName = (vimrcs: VimrcFile[]) => { 81 | if (vimrcs.length === 1) { 82 | return; 83 | } 84 | const urls = vimrcs.map((vimrc) => vimrc.url).sort(); 85 | const commonPath = extractCommonPath(urls); 86 | const commonLen = commonPath.length; 87 | vimrcs.forEach((vimrc) => { 88 | vimrc.name = vimrc.url.slice(commonLen); 89 | }); 90 | }; 91 | 92 | 93 | export class ReadingVimrcRepos { 94 | readonly repository: string; 95 | readonly baseWorkDir: string; 96 | readonly githubUser: string; 97 | readonly githubAPIToken: string; 98 | siteUpdater: GitRepositoryUpdater | undefined; 99 | wikiUpdater: GitRepositoryUpdater | undefined; 100 | 101 | constructor(repository: string, baseWorkDir: string, githubUser: string, githubAPIToken: string) { 102 | this.repository = repository; 103 | this.baseWorkDir = baseWorkDir; 104 | this.githubUser = githubUser; 105 | this.githubAPIToken = githubAPIToken; 106 | } 107 | 108 | get nextYAMLFilePath(): string { 109 | return this.siteUpdater ? path.join(this.siteUpdater.workDir, "_data", "next.yml") : ""; 110 | } 111 | 112 | get archiveYAMLFilePath(): string { 113 | return this.siteUpdater ? path.join(this.siteUpdater.workDir, "_data", "archives.yml") : ""; 114 | } 115 | 116 | async readNextYAMLData(): Promise { 117 | const text = await fs.readFile(this.nextYAMLFilePath, "utf-8"); 118 | return (YAML.load(text) as NextVimrc[])[0]; 119 | } 120 | 121 | async readArchiveYAMLData(): Promise { 122 | const text = await fs.readFile(this.archiveYAMLFilePath, "utf-8"); 123 | return YAML.load(text) as ArchiveVimrc[]; 124 | } 125 | 126 | async readTargetMembers(): Promise> { 127 | const yaml = await this.readArchiveYAMLData(); 128 | return new Set(yaml.map((entry) => entry.author.name)); 129 | } 130 | 131 | async setup(): Promise { 132 | { 133 | const reposURL = `https://${this.githubUser}:${this.githubAPIToken}@github.com/${this.repository}`; 134 | const workDir = path.join(this.baseWorkDir, "gh-pages"); 135 | const opts = {branch: "gh-pages"}; 136 | this.siteUpdater = new GitRepositoryUpdater(reposURL, workDir, opts); 137 | } 138 | 139 | { 140 | const reposURL = `https://${this.githubUser}:${this.githubAPIToken}@github.com/${this.repository}.wiki`; 141 | const workDir = path.join(this.baseWorkDir, "wiki"); 142 | const opts = {branch: "master"}; 143 | this.wikiUpdater = new GitRepositoryUpdater(reposURL, workDir, opts); 144 | } 145 | 146 | await Promise.all([this.siteUpdater.setup(), this.wikiUpdater.setup()]); 147 | } 148 | 149 | async finish(resultData: ArchiveVimrc): Promise { 150 | if (!this.siteUpdater) { 151 | throw new Error("need setup"); 152 | } 153 | await this.siteUpdater.updateReposToLatest(); 154 | await this._updateArchiveYAML(resultData); 155 | await this._addArchiveMarkdown(resultData.id); 156 | await this.siteUpdater.commitAndPush(`Add archive for #${resultData.id}`); 157 | await this.removeWikiEntry(resultData); 158 | } 159 | 160 | async next(nexts: string[], resultData: ArchiveVimrc): Promise { 161 | if (!this.siteUpdater) { 162 | throw new Error("need setup"); 163 | } 164 | await this.siteUpdater.updateReposToLatest(); 165 | const nextData = await this._updateNextYAML(nexts, resultData); 166 | const message = `Update the next information: #${nextData.id} ${nextData.author.name}`; 167 | await this.siteUpdater.commitAndPush(message); 168 | return nextData; 169 | } 170 | 171 | async addWikiEntry(requester: string, author: string, urlString: string, comment?: string): Promise { 172 | if (!this.wikiUpdater) { 173 | throw new Error("need setup"); 174 | } 175 | await this.wikiUpdater.updateReposToLatest(); 176 | const needPush = await this._addNameToWikiFile(requester, author, urlString, comment); 177 | if (needPush) { 178 | await this.wikiUpdater.commitAndPush(`Add ${author}`); 179 | } 180 | return needPush; 181 | } 182 | 183 | async removeWikiEntry(resultData: ArchiveVimrc): Promise { 184 | if (!this.wikiUpdater) { 185 | throw new Error("need setup"); 186 | } 187 | const name = resultData.author.name; 188 | await this.wikiUpdater.updateReposToLatest(); 189 | const needPush = await this._removeNameFromWikiFile(name); 190 | if (needPush) { 191 | await this.wikiUpdater.commitAndPush(`Remove ${name} (#${resultData.id})`); 192 | } 193 | return needPush; 194 | } 195 | 196 | async _addNameToWikiFile(requester: string, author: string, urlString: string, comment?: string): Promise { 197 | const rawURL = makeRawURL(urlString); 198 | const res = await fetch(rawURL); 199 | const text = await res.text(); 200 | const lineNum = text.split("\n").length; 201 | return await this._updateRequestFile((lines) => { 202 | if (0 <= lines.findIndex((line) => line.startsWith(`${author} `))) { 203 | return lines; 204 | } 205 | const index = lines.findIndex((line) => /^\.\.\./.test(line)); 206 | if (0 <= index) { 207 | const newLine = makeWikiRequestLine(author, requester, urlString, lineNum, comment); 208 | lines.splice(index, 0, newLine); 209 | } 210 | return lines; 211 | }); 212 | } 213 | 214 | async _removeNameFromWikiFile(name: string): Promise { 215 | return await this._updateRequestFile((lines) => { 216 | const namePat = new RegExp(`^${name}\\s*\\|`); 217 | return lines.filter((line) => !namePat.test(line)); 218 | }); 219 | } 220 | 221 | async _updateRequestFile(callback: (lines: string[]) => string[]): Promise { 222 | if (!this.wikiUpdater) { 223 | throw new Error("need setup"); 224 | } 225 | const requestFile = path.join(this.wikiUpdater.workDir, "Request.md"); 226 | const content = await fs.readFile(requestFile, "utf-8"); 227 | const lines = content.split("\n"); 228 | const origLength = lines.length; 229 | const newLines = callback(lines); 230 | 231 | // This function only updates with add/remove lines 232 | if (origLength === newLines.length) { 233 | return false; 234 | } 235 | await fs.writeFile(requestFile, newLines.join("\n")); 236 | return true; 237 | } 238 | 239 | async _updateArchiveYAML(resultData: ArchiveVimrc): Promise { 240 | if (!this.siteUpdater) { 241 | throw new Error("need setup"); 242 | } 243 | const yamlPath = path.join(this.siteUpdater.workDir, "_data", "archives.yml"); 244 | const yamlEntry = YAML.dump([resultData], {lineWidth: 1000}); 245 | await fs.appendFile(yamlPath, yamlEntry); 246 | } 247 | 248 | async _addArchiveMarkdown(id: number): Promise { 249 | if (!this.siteUpdater) { 250 | throw new Error("need setup"); 251 | } 252 | const archivePath = path.join(this.siteUpdater.workDir, "archive", printf("%03d.md", id)); 253 | const archiveBody = printf(TEMPLATE_TEXT, id, id); 254 | await fs.writeFile(archivePath, archiveBody); 255 | } 256 | 257 | async _updateNextYAML(nexts: string[], resultData: ArchiveVimrc): Promise { 258 | const urls = nexts.filter((next) => next.match(/^http/)); 259 | const others = nexts.filter((next) => !next.match(/^http/)); 260 | const part = others.find((o) => /^.+編$/.test(o)) || null; 261 | 262 | if (urls.length === 0 && !part) { 263 | throw "Need {nexts} parameter"; 264 | } 265 | 266 | const isContinuous = urls.length === 0; 267 | const nextVimrcURLs = 268 | isContinuous ? resultData.vimrcs.map((vimrc) => vimrc.url) : urls; 269 | 270 | const nextVimrcData = nextVimrcURLs.map(makeGithubURLInfo); 271 | const data = nextVimrcData[0]; 272 | 273 | const nextData = await this.readNextYAMLData(); 274 | const date = new Date(nextData.date); 275 | if (date.getTime() < Date.now()) { 276 | nextData.id++; 277 | nextData.date = nextWeek(nextData.date); 278 | } 279 | nextData.author = data.author; 280 | nextData.vimrcs = nextVimrcData.map((data) => data.vimrc); 281 | refineName(nextData.vimrcs); 282 | nextData.part = part; 283 | 284 | const yamlPath = this.nextYAMLFilePath; 285 | await fs.writeFile(yamlPath, YAML.dump([nextData])); 286 | return nextData; 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/scripts/reading-vimrc.ts: -------------------------------------------------------------------------------- 1 | // Description: 2 | // Supports reading vimrc. 3 | // https://vim-jp.org/reading-vimrc/ 4 | // 5 | // Dependencies: 6 | // js-yaml: 4.1.0 7 | // node-fetch: 3.2.10 8 | // printf: 0.6.1 9 | // @octokit/rest: 19.0.4 10 | // 11 | // Configuration: 12 | // HUBOT_READING_VIMRC_ENABLE 13 | // Set non-empty value to enable this script. 14 | // HUBOT_READING_VIMRC_ROOM_NAME 15 | // Target room name. 16 | // This script works only on the specified room. 17 | // HUBOT_READING_VIMRC_ADMIN_USERS 18 | // Admin users. This is comma separated list. 19 | // Some commands can be executed by admin users only. 20 | // HUBOT_READING_VIMRC_HOMEPAGE 21 | // Site URL of reading vimrc. 22 | // This must end with "/". 23 | // HUBOT_READING_VIMRC_GITHUB_REPOS 24 | // Git repository of reading-vimrc gh-pages. (Like "vim-jp/reading-vimrc") 25 | // HUBOT_READING_VIMRC_WORK_DIR 26 | // Working directory. 27 | // This script can update the reading vimrc sites on GitHub Pages. 28 | // HUBOT_READING_VIMRC_GITHUB_USER 29 | // GitHub User ID to manipulate the reading-vimrc repository. 30 | // HUBOT_READING_VIMRC_GITHUB_API_TOKEN 31 | // GitHub API token to access to GitHub. 32 | // write:public_key scope is needed. 33 | // This is also used for fetching the latest commit hash. 34 | // 35 | // Commands: 36 | // !reading_vimrc start - Start the reading vimrc. Admin only. 37 | // !reading_vimrc stop - Stop the reading vimrc. Admin only. 38 | // !reading_vimrc reset - Reset the members of current reading vimrc. Admin only. 39 | // !reading_vimrc restore - Restore the members. 40 | // !reading_vimrc status - Show the status(started or stopped). 41 | // !reading_vimrc member - List the members of current reading vimrc. 42 | // !reading_vimrc member_with_count - List of members with said count. 43 | // !reading_vimrc next {vimrc} ... - Update next vimrc. 44 | // !reading_vimrc request[!] {vimrc} - Add a vimrc to request page. 45 | // !reading_vimrc role - Show your role. 46 | // !reading_vimrc help - Show the help. 47 | // 48 | // Author: 49 | // thinca 50 | 51 | import * as path from "path"; 52 | import {URL} from "url"; 53 | import * as hubot from "hubot"; 54 | import {default as fetch, HeadersInit} from "node-fetch"; 55 | import printf from "printf"; 56 | 57 | import {ArchiveVimrc, NextVimrc, VimrcFile} from "../lib/types"; 58 | import {ReadingVimrcProgressor} from "../lib/reading_vimrc_progressor"; 59 | import {ReadingVimrcRepos} from "../lib/reading_vimrc_repos"; 60 | 61 | export = (() => { 62 | if (!process.env.HUBOT_READING_VIMRC_ENABLE) { 63 | return () => { 64 | // do nothing 65 | }; 66 | } 67 | 68 | const ROOM_NAME = process.env.HUBOT_READING_VIMRC_ROOM_NAME; 69 | const ADMIN_USERS = (process.env.HUBOT_READING_VIMRC_ADMIN_USERS || "").split(/,/); 70 | const HOMEPAGE_BASE = process.env.HUBOT_READING_VIMRC_HOMEPAGE || "https://vim-jp.org/reading-vimrc/"; 71 | const GITHUB_API_TOKEN = process.env.HUBOT_READING_VIMRC_GITHUB_API_TOKEN; 72 | 73 | const REQUEST_PAGE = "https://github.com/vim-jp/reading-vimrc/wiki/Request"; 74 | 75 | const helpMessage = `vimrc読書会サポート bot です 76 | 77 | !reading_vimrc {command} [{args}...] 78 | 79 | start : 会の開始、"member" は "reset" される(owner) 80 | stop : 会の終了(owner) 81 | reset : "member" をリセット(owner) 82 | restore : "member" を1つ前に戻す(owner) 83 | status : ステータスの出力 84 | member : "start" ~ "stop" の間に発言した人を列挙 85 | member_with_count : "member" に発言数も追加して列挙 86 | next {url}... : 次回分更新(owner) 87 | request[!] {url}... : 読みたい vimrc をリクエストページに追加 88 | help : 使い方を出力`; 89 | 90 | const createStartingMessage = (data: NextVimrc, vimrcs: VimrcFile[]): string => { 91 | return `=== 第${data.id}回 vimrc読書会 === 92 | - 途中参加/途中離脱OK。声をかける必要はありません 93 | - 読む順はとくに決めないので、好きなように読んで好きなように発言しましょう 94 | - vimrc 内の特定位置を参照する場合は行番号で L100 や L100-110 のように指定します${ 95 | 1 < vimrcs.length ? ` 96 | - 今回は複数ファイルがあるため、filename#L100 のようにファイル名を指定します 97 | - 省略した場合は直前に参照しファイルか、それがない場合は適当なファイルになります` : "" 98 | } 99 | - 特定の相手に発言/返事する場合は \`@username\` を付けます 100 | - 一通り読み終わったら、読み終わったことを宣言してください。終了の目安にします 101 | - ただの目安なので、宣言してからでも読み返して全然OKです${ 102 | (() => { 103 | switch (data.part) { 104 | case "前編": 105 | return ` 106 | - 今回は${data.part}です。終了時間になったら、途中でも強制終了します 107 | - 続きは来週読みます 108 | - いつも通り各自のペースで読むので、どこまで読んだか覚えておきましょう`; 109 | case "中編": 110 | return ` 111 | - 今回は${data.part}です。終了時間になったら、途中でも強制終了します 112 | - 前回参加していた方は続きから、参加していなかったら最初からになります 113 | - 続きは来週読みます 114 | - いつも通り各自のペースで読むので、どこまで読んだか覚えておきましょう`; 115 | case "後編": 116 | return ` 117 | - 今回は${data.part}です。前回参加した人は続きから読みましょう`; 118 | } 119 | return ""; 120 | })()} 121 | ${createSumaryMessage(data, vimrcs)}`; 122 | }; 123 | 124 | const splitMessage = (message: string): string[] => { 125 | return [...message.matchAll(/[^]{1,4096}(?:\n|$)/g)].map(([part]) => part.trimEnd()); 126 | }; 127 | 128 | const createSumaryMessage = (data: NextVimrc, vimrcs: VimrcFile[]): string => { 129 | const mdVimrcs = vimrcs.map((vimrc) => ` 130 | [${vimrc.name}](${vimrc.url}) ([DL](${vimrc.raw_url}))`).join(""); 131 | return `今回読む vimrc: [${data.author.name}](${data.author.url}) さん:${mdVimrcs}`; 132 | }; 133 | 134 | const generateResultData = async (readingVimrcRepos: ReadingVimrcRepos, readingVimrc: ReadingVimrcProgressor): Promise => { 135 | const nextData: ArchiveVimrc = await readingVimrcRepos.readNextYAMLData(); 136 | if (nextData.id === readingVimrc.id) { 137 | nextData.members = readingVimrc.members.sort(); 138 | nextData.log = readingVimrc.logURL; 139 | nextData.vimrcs = readingVimrc.vimrcs.map((vimrc) => Object.assign({}, vimrc)); 140 | } 141 | return nextData; 142 | }; 143 | 144 | const lastCommitHash = (() => { 145 | // XXX: Should cache expire? 146 | const hashes = new Map(); 147 | return async (url: string): Promise => { 148 | const [, place] = url.match(/https:\/\/github\.com\/([^/]+\/[^/]+)/) || []; 149 | // XXX Should `hashes` lock? How? 150 | const cached = hashes.get(place); 151 | if (cached) { 152 | return cached; 153 | } 154 | const apiURL = `https://api.github.com/repos/${place}/commits/HEAD`; 155 | const headers: HeadersInit = {"Accept": "application/vnd.github.VERSION.sha"}; 156 | if (GITHUB_API_TOKEN) { 157 | headers["Authorization"] = `token ${GITHUB_API_TOKEN}`; 158 | } 159 | const res = await fetch(apiURL, {headers}); 160 | if (!res.ok) { 161 | throw new Error(`GET ${apiURL} was failed:${res.status}`); 162 | } 163 | const version = await res.text(); 164 | hashes.set(place, version); 165 | return version; 166 | }; 167 | })(); 168 | 169 | const makeMatrixURL = (message: hubot.Message): string => { 170 | return `https://matrix.to/#/${message.room}/${message.id}`; 171 | }; 172 | 173 | const getUsername = (user: hubot.User): string => { 174 | return (user.login as string) || user.name; // user.name for shell adapter 175 | }; 176 | 177 | const isAdmin = (user: hubot.User): boolean => { 178 | return ADMIN_USERS.includes(user.id); 179 | }; 180 | 181 | const PLUGIN_REPO_PATTERN = 182 | /^\s*(?:"\s*)?(?:Plug(?:in)?|NeoBundle\w*|Jetpack|call\s+(?:dein|minpac|jetpack)#add\()\s*['"]([^'"]+)/gm; 183 | const extractPluginURLs = (text: string): {repo: string, url: string}[] => { 184 | const repos = []; 185 | let result; 186 | while((result = PLUGIN_REPO_PATTERN.exec(text)) !== null) { 187 | repos.push(result[1]); 188 | } 189 | const repoURLs = repos.map((repo) => { 190 | let url = repo; 191 | if (!url.includes("/")) { 192 | url = `vim-scripts/${url}`; 193 | } 194 | if (/^[^/]+\/[^/]+$/.test(url)) { 195 | url = `https://github.com/${url}`; 196 | } 197 | return {repo, url}; 198 | }); 199 | return repoURLs; 200 | }; 201 | 202 | interface ListenerOptions { 203 | readingVimrc: boolean; 204 | admin: boolean; 205 | } 206 | 207 | return (robot: hubot.Robot) => { 208 | const toFixedVimrc = async (vimrc: VimrcFile): Promise => { 209 | const hash = vimrc.hash || await lastCommitHash(vimrc.url); 210 | vimrc.hash = hash; 211 | const url = vimrc.url.replace(/blob\/\w+\//, `blob/${hash}/`); 212 | const raw_url = vimrc.url 213 | .replace(/https:\/\/github/, "https://raw.githubusercontent") 214 | .replace(/blob\/[^/]+\//, `${hash}/`); 215 | return {name: vimrc.name, url, raw_url, hash}; 216 | }; 217 | 218 | let readingVimrcRepos: ReadingVimrcRepos; 219 | (async () => { 220 | const githubRepos = process.env.HUBOT_READING_VIMRC_GITHUB_REPOS; 221 | const workDir = process.env.HUBOT_READING_VIMRC_WORK_DIR; 222 | const githubUser = process.env.HUBOT_READING_VIMRC_GITHUB_USER; 223 | if (!githubRepos || !workDir || !githubUser || !GITHUB_API_TOKEN) { 224 | return; 225 | } 226 | const repos = new ReadingVimrcRepos(githubRepos, workDir, githubUser, GITHUB_API_TOKEN); 227 | try { 228 | await repos.setup(); 229 | readingVimrcRepos = repos; 230 | robot.logger.info("ReadingVimrcRepos: setup succeeded."); 231 | } catch (e) { 232 | robot.logger.error("ReadingVimrcRepos: setup failed.", e); 233 | } 234 | })(); 235 | 236 | const progressor = new ReadingVimrcProgressor(); 237 | 238 | robot.listenerMiddleware((context, next, done) => { 239 | if (!context.response) { 240 | next(done); 241 | return; 242 | } 243 | const user = context.response.envelope.user; 244 | const room = context.response.envelope.room; 245 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 246 | const options: ListenerOptions = (context).listener.options; 247 | 248 | if (options.readingVimrc && room !== ROOM_NAME) { 249 | done(); 250 | return; 251 | } 252 | if (options.admin && !isAdmin(user)) { 253 | robot.logger.warning( 254 | "A non admin user tried to use an admin command: %s: %s", 255 | user.login || user.name, 256 | context.response.message.text, 257 | ); 258 | done(); 259 | return; 260 | } 261 | next(done); 262 | }); 263 | 264 | robot.hear(/.*/i, {readingVimrc: true}, (res: hubot.Response) => { 265 | if (!(/^!reading_vimrc/.test(res.message.text || ""))) { 266 | progressor.addMessage(res.message); 267 | } 268 | }); 269 | robot.hear(/^(?:(\S+)#)??(L\d+(?:[-+]L?\d+)?(?:(?:\s+L|,L?)\d+(?:[-+]L?\d+)?)*)/, {readingVimrc: true}, (res: hubot.Response) => { 270 | if (!progressor.isRunning) { 271 | return; 272 | } 273 | const [, name, linesInfo] = res.match; 274 | const username = getUsername(res.envelope.user); 275 | const [url, content] = progressor.getVimrcFile(name, username); 276 | if (!url || !content) { 277 | if (name != null) { 278 | res.send(`File not found: ${name}`); 279 | } 280 | return; 281 | } 282 | const filename = path.basename(url); 283 | const text = linesInfo.split(/[\s,]+/) 284 | .map((info) => info.match(/L?(\d+)(?:([-+])L?(\d+))?/)) 285 | .filter((matchResult): matchResult is RegExpMatchArray => matchResult != null) 286 | .map((matchResult) => { 287 | const startLine = Number.parseInt(matchResult[1]); 288 | const flag = matchResult[2]; 289 | const secondNum = matchResult[3] ? Number.parseInt(matchResult[3]) : undefined; 290 | const endLine = secondNum && flag === "+" ? startLine + secondNum : secondNum; 291 | const lines = progressor.getVimrcLines(content, startLine, endLine); 292 | if (lines.length === 0) { 293 | return `無効な範囲です: ${matchResult[0]}`; 294 | } 295 | let fragment = `#L${startLine}`; 296 | if (endLine) { 297 | fragment += `-L${endLine}`; 298 | } 299 | const headURL = `[${filename}${fragment}](${url}${fragment})`; 300 | const code = lines.map((line, n) => printf("%4d | %s", n + startLine, line)).join("\n"); 301 | const repoURLs = extractPluginURLs(lines.join("\n")).map(({repo, url}) => `[${repo}](${url})`); 302 | return [ 303 | headURL, 304 | "```vim", 305 | code, 306 | "```", 307 | ].concat(repoURLs).join("\n"); 308 | }).join("\n"); 309 | res.send(text); 310 | }); 311 | robot.hear(/^!reading_vimrc[\s]+start$/i, {readingVimrc: true, admin: true}, async (res: hubot.Response) => { 312 | const nextData = await readingVimrcRepos.readNextYAMLData(); 313 | const logURL = makeMatrixURL(res.envelope.message); 314 | const vimrcs = await Promise.all(nextData.vimrcs.map(toFixedVimrc)); 315 | progressor.start(nextData.id, logURL, vimrcs, nextData.part); 316 | vimrcs.forEach(async (vimrc) => { 317 | if (!vimrc.raw_url) { 318 | return; 319 | } 320 | const response = await fetch(vimrc.raw_url); 321 | if (response.ok) { 322 | const body = await response.text(); 323 | progressor.setVimrcContent(vimrc.url, body); 324 | } else { 325 | res.send(`ERROR: ${vimrc.name} の読み込みに失敗しました`); 326 | robot.logger.error(`Fetch vimrc failed: ${response.status}: ${vimrc.raw_url}`); 327 | } 328 | }); 329 | splitMessage(createStartingMessage(nextData, vimrcs)).forEach((mes) => res.send(mes)); 330 | }); 331 | robot.hear(/^!reading_vimrc\s+stop$/, {readingVimrc: true, admin: true}, async (res: hubot.Response) => { 332 | progressor.stop(); 333 | if (!progressor.part || progressor.part === "後編") { 334 | res.send(`おつかれさまでした。次回読む vimrc を決めましょう!\n${REQUEST_PAGE}`); 335 | } else { 336 | res.send("おつかれさまでした。次回は続きを読むので、どこまで読んだか覚えておきましょう!"); 337 | } 338 | if (readingVimrcRepos) { 339 | try { 340 | const resultData = await generateResultData(readingVimrcRepos, progressor); 341 | await readingVimrcRepos.finish(resultData); 342 | const id = resultData.id; 343 | const url = `${HOMEPAGE_BASE}archive/${printf("%03d", id)}.html`; 344 | res.send(`アーカイブページを更新しました: [第${id}回](${url})`); 345 | } catch (error) { 346 | res.send(`ERROR: ${error}`); 347 | robot.logger.error("Error occurred while updating a result:", error); 348 | } 349 | } 350 | }); 351 | robot.hear(/^!reading_vimrc\s+reset$/, {readingVimrc: true, admin: true}, (res: hubot.Response) => { 352 | progressor.reset(); 353 | res.send("reset"); 354 | }); 355 | robot.hear(/^!reading_vimrc\s+restore$/, {readingVimrc: true, admin: true}, (res: hubot.Response) => { 356 | progressor.restore(); 357 | res.send("restored"); 358 | }); 359 | robot.hear(/^!reading_vimrc\s+status$/, {readingVimrc: true}, (res: hubot.Response) => { 360 | res.send(progressor.status); 361 | }); 362 | robot.hear(/^!reading_vimrc\s+members?$/, {readingVimrc: true}, (res: hubot.Response) => { 363 | const members = progressor.members; 364 | if (members.length === 0) { 365 | res.send("だれもいませんでした"); 366 | } else { 367 | const lines = members; 368 | lines.sort(); 369 | lines.push("\n", progressor.logURL); 370 | res.send(lines.join("\n")); 371 | } 372 | }); 373 | robot.hear(/^!reading_vimrc\s+members?_with_count$/, {readingVimrc: true}, (res: hubot.Response) => { 374 | const messages = progressor.messages; 375 | if (messages.length === 0) { 376 | res.send("だれもいませんでした"); 377 | } else { 378 | const entries = messages 379 | .map((mes) => getUsername(mes.user)) 380 | .reduce((map, currentValue) => { 381 | map.set(currentValue, (map.get(currentValue) || 0) + 1); 382 | return map; 383 | }, new Map()) 384 | .entries(); 385 | const lines = [...entries] 386 | .sort((a, b) => a < b ? -1 : a > b ? 1 : 0) 387 | .map(([name, count]) => printf("%03d回 : %s", count, name)); 388 | lines.push("\n", progressor.logURL); 389 | res.send(lines.join("\n")); 390 | } 391 | }); 392 | robot.hear(/^!reading_vimrc\s+next\s+([^]+)/, {readingVimrc: true, admin: true}, async (res: hubot.Response) => { 393 | if (!readingVimrcRepos) { 394 | return; 395 | } 396 | try { 397 | const urls = res.match[1].split(/\s+/); 398 | const resultData = await generateResultData(readingVimrcRepos, progressor); 399 | const nextData = await readingVimrcRepos.next(urls, resultData); 400 | res.send(`次回予告を更新しました:\n次回 第${nextData.id}回 ${nextData.date} [${nextData.author.name}](${nextData.author.url}) さん`); 401 | } catch (error) { 402 | res.send(`ERROR: ${error}`); 403 | robot.logger.error("Error occurred while updating a result:", error); 404 | } 405 | }); 406 | robot.hear(/^!reading_vimrc\s+request(!?)\s+(\S+)(?:\s+([^]+))?/, {readingVimrc: true}, async (res: hubot.Response) => { 407 | if (!readingVimrcRepos) { 408 | return; 409 | } 410 | const update = async () => { 411 | try { 412 | const updated = await readingVimrcRepos.addWikiEntry(requester, author, url, comment); 413 | if (updated) { 414 | res.send(`vimrc を[リクエストページ](${REQUEST_PAGE})に追加しました`); 415 | } else { 416 | res.send(`何らかの理由により、[リクエストページ](${REQUEST_PAGE})は更新されませんでした`); 417 | } 418 | } catch (error) { 419 | res.send(`ERROR: ${error}`); 420 | robot.logger.error("Error occurred while updating a result:", error); 421 | } 422 | }; 423 | 424 | const force = res.match[1] === "!"; 425 | 426 | const [, , url, comment] = res.match; 427 | const requester = res.envelope.user.name; 428 | const author = new URL(url).pathname.split("/")[1]; 429 | 430 | if (force) { 431 | await update(); 432 | return; 433 | } 434 | 435 | const memberSet = await readingVimrcRepos.readTargetMembers(); 436 | if (memberSet.has(author)) { 437 | res.send(`${author} さんの vimrc は過去に読まれています。\n再リクエストの場合は request! を使ってください`); 438 | } else { 439 | await update(); 440 | } 441 | }); 442 | robot.hear(/^!reading_vimrc\s+role/, {readingVimrc: true}, async (res: hubot.Response) => { 443 | const role = isAdmin(res.message.user) ? "admin" : "member"; 444 | res.send(role); 445 | }); 446 | robot.hear(/^!reading_vimrc\s+help/, {readingVimrc: true}, (res: hubot.Response) => { 447 | res.send(helpMessage); 448 | }); 449 | }; 450 | 451 | })(); 452 | --------------------------------------------------------------------------------