├── __mocks__ ├── index.js └── knex.js ├── .babelrc ├── src ├── model │ ├── Interaction.js │ ├── Database.js │ ├── Task.js │ ├── G0ver.js │ ├── Project.js │ └── Event.js ├── help │ ├── camelgetter.js │ ├── queryWithConnection.js │ ├── sqlparser.js │ └── __tests__ │ │ ├── camelgetter.js │ │ └── sqlpaser.js ├── command │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── all.test.js.snap │ │ │ ├── out.test.js.snap │ │ │ ├── search.test.js.snap │ │ │ ├── jothon.test.js.snap │ │ │ ├── del.test.js.snap │ │ │ ├── slogan.test.js.snap │ │ │ ├── add.test.js.snap │ │ │ ├── create.test.js.snap │ │ │ ├── whois.test.js.snap │ │ │ ├── whoami.test.js.snap │ │ │ ├── cancel.test.js.snap │ │ │ ├── remove.test.js.snap │ │ │ ├── projects.test.js.snap │ │ │ ├── events.test.js.snap │ │ │ ├── in.test.js.snap │ │ │ ├── unfollow.test.js.snap │ │ │ ├── notice.test.js.snap │ │ │ ├── follow.test.js.snap │ │ │ └── help.test.js.snap │ │ ├── help.test.js │ │ ├── all.test.js │ │ ├── out.test.js │ │ ├── whoami.test.js │ │ ├── whois.test.js │ │ ├── search.test.js │ │ ├── del.test.js │ │ ├── slogan.test.js │ │ ├── add.test.js │ │ ├── events.test.js │ │ ├── projects.test.js │ │ ├── create.test.js │ │ ├── cancel.test.js │ │ ├── remove.test.js │ │ ├── in.test.js │ │ ├── follow.test.js │ │ ├── unfollow.test.js │ │ ├── jothon.test.js │ │ └── notice.test.js │ ├── all.js │ ├── slogan.js │ ├── out.js │ ├── search.js │ ├── del.js │ ├── whois.js │ ├── add.js │ ├── projects.js │ ├── whoami.js │ ├── events.js │ ├── cancel.js │ ├── remove.js │ ├── follow.js │ ├── unfollow.js │ ├── in.js │ ├── create.js │ ├── jothon.js │ ├── notice.js │ ├── help.js │ └── index.js ├── type │ ├── GraphQLG0ver.js │ ├── GraphQLPrimary.js │ ├── GraphQLProject.js │ ├── GraphQLJsonField.js │ └── __tests__ │ │ ├── GraphQLPrimary.test.js │ │ └── GraphQLJsonField.test.js ├── query │ ├── G0verQuery.js │ └── __tests__ │ │ └── G0verQuery.test.js ├── Schema.js ├── mutation │ ├── UpdateG0verMutation.js │ └── __tests__ │ │ └── UpdateG0verMutation.test.js ├── Slack.js └── app.js ├── migrations ├── 20171021163500_uuid.js ├── 20171125105501_add-channel-into-g0ver.js ├── 20171021168523_add-slogan.js ├── 20161008130912_relate-g0ver-with-project.js ├── 20161008105730_create-table-g0ver.js ├── 20171021163523_create-task.js ├── 20161008120007_create-table-project.js ├── 20171105145638_create-table-event.js ├── 20171105172543_add-followers.js ├── 20171021134832_redesign-g0ver.js └── 20171105010345_update-project-table.js ├── knexfile.js ├── .eslintrc ├── seeds ├── g0ver.js └── project.js ├── script └── migration-schema.js ├── .gitignore ├── README.md ├── schema.graphql ├── package.json └── schema.json /__mocks__/index.js: -------------------------------------------------------------------------------- 1 | jest.mock('../src/Slack'); 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"] 3 | } 4 | -------------------------------------------------------------------------------- /src/model/Interaction.js: -------------------------------------------------------------------------------- 1 | export default new Map(); 2 | -------------------------------------------------------------------------------- /__mocks__/knex.js: -------------------------------------------------------------------------------- 1 | import knex from 'jest-mock-knex'; 2 | 3 | export { client } from 'jest-mock-knex'; 4 | 5 | export default knex; 6 | 7 | process.setMaxListeners(0); 8 | -------------------------------------------------------------------------------- /migrations/20171021163500_uuid.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') 3 | ); 4 | 5 | exports.down = knex => ( 6 | knex.raw('DROP EXTENSION IF EXISTS "uuid-ossp"') 7 | ); 8 | -------------------------------------------------------------------------------- /src/help/camelgetter.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export default function camelgetter(data, key) { 4 | const object = _.isObject(data) ? data : {}; 5 | return _.isNil(object[key]) ? object[_.snakeCase(key)] : object[key]; 6 | } 7 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: 'pg', 3 | connection: process.env.DATABASE_URL || { 4 | host: 'localhost', 5 | port: 5432, 6 | user: 'postgres', 7 | password: '', 8 | database: 'g0vhub', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /migrations/20171125105501_add-channel-into-g0ver.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => knex.schema.table('g0ver', (table) => { 2 | table.string('channel'); 3 | }); 4 | 5 | exports.down = knex => knex.schema.table('event', (table) => { 6 | table.dropColumn('channel'); 7 | }); 8 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/all.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`all command when g0ver have been tasks 1`] = ` 4 | Array [ 5 | "select * from task where expired_at > DATE and deleted_at is null limit 1000", 6 | ] 7 | `; 8 | -------------------------------------------------------------------------------- /migrations/20171021168523_add-slogan.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | knex.schema.table('g0ver', (table) => { 3 | table.string('slogan'); 4 | }) 5 | ); 6 | 7 | exports.down = knex => ( 8 | knex.schema.table('g0ver', (table) => { 9 | table.dropColumn('slogan'); 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /src/model/Database.js: -------------------------------------------------------------------------------- 1 | import knex from 'knex'; 2 | import { Model as GraphqlModel } from 'graphql-tower'; 3 | import config from '../../knexfile'; 4 | 5 | const db = knex(config); 6 | 7 | export default db; 8 | 9 | export class Model extends GraphqlModel { 10 | static database = db; 11 | } 12 | -------------------------------------------------------------------------------- /src/command/all.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Task from '../model/Task'; 3 | 4 | export default async function () { 5 | const task = new Task(); 6 | task.where('expired_at', '>', new Date()); 7 | 8 | return _.map(await task.fetchAll(), ({ user, note }) => `<@${user}> in ${note}`).join('\n'); 9 | } 10 | -------------------------------------------------------------------------------- /src/command/__tests__/help.test.js: -------------------------------------------------------------------------------- 1 | import Slack from '../../Slack'; 2 | import index from '../'; 3 | 4 | describe('help command', () => { 5 | const data = { channel: 'D100', name: 'yutin', text: 'help' }; 6 | 7 | it('default', async () => { 8 | await index(data); 9 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "no-console": 0, 6 | }, 7 | "globals": { 8 | "jest": true, 9 | "describe": true, 10 | "it": true, 11 | "expect": true, 12 | "beforeAll": true, 13 | "afterAll": true, 14 | "beforeEach": true, 15 | "afterEach": true, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /src/command/slogan.js: -------------------------------------------------------------------------------- 1 | import G0ver from '../model/G0ver'; 2 | 3 | export default async function ({ slogan }, { user }) { 4 | if (!slogan) return null; 5 | 6 | const g0ver = await G0ver.load(user) || await new G0ver({ id: user }).insert(); 7 | g0ver.slogan = slogan; 8 | 9 | await g0ver.save(); 10 | 11 | return `done it, setting ${slogan}.`; 12 | } 13 | -------------------------------------------------------------------------------- /src/command/out.js: -------------------------------------------------------------------------------- 1 | import Task from '../model/Task'; 2 | 3 | export default async function ({ note }, { user, name }) { 4 | const task = new Task({ user }); 5 | if (!await task.where('expired_at', '>', new Date()).fetch()) { 6 | return `${name} 目前沒有入坑`; 7 | } 8 | 9 | await task.save({ expiredAt: new Date() }); 10 | 11 | return `${name} 跟大家分享 ${task.note} 吧!`; 12 | } 13 | -------------------------------------------------------------------------------- /migrations/20161008130912_relate-g0ver-with-project.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | knex.schema.createTable('g0ver_project', (table) => { 3 | table.integer('g0ver_id'); 4 | table.integer('project_id'); 5 | table.primary(['g0ver_id', 'project_id']); 6 | table.timestamps(); 7 | }) 8 | ); 9 | 10 | exports.down = knex => ( 11 | knex.schema.dropTable('g0ver_project') 12 | ); 13 | -------------------------------------------------------------------------------- /src/type/GraphQLG0ver.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLList, GraphQLString } from 'graphql'; 2 | import GraphQLPrimary from './GraphQLPrimary'; 3 | 4 | export default new GraphQLObjectType({ 5 | name: 'G0ver', 6 | fields: () => ({ 7 | id: { type: GraphQLPrimary }, 8 | username: { type: GraphQLString }, 9 | skills: { type: new GraphQLList(GraphQLString) }, 10 | }), 11 | }); 12 | -------------------------------------------------------------------------------- /migrations/20161008105730_create-table-g0ver.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | knex.schema.createTable('g0ver', (table) => { 3 | table.increments('id').primary(); 4 | table.string('username').notNullable().index(); 5 | table.json('skill'); 6 | table.timestamps(); 7 | table.dateTime('deleted_at'); 8 | }) 9 | ); 10 | 11 | exports.down = knex => ( 12 | knex.schema.dropTable('g0ver') 13 | ); 14 | -------------------------------------------------------------------------------- /seeds/g0ver.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker'); 2 | const _ = require('lodash'); 3 | 4 | exports.seed = (knex, Promise) => ( 5 | knex('g0ver').truncate() 6 | .then(() => ( 7 | Promise.all(_.range(1, 100).map(() => ( 8 | knex('g0ver').insert({ 9 | username: faker.internet.userName(), 10 | skill: JSON.stringify(_.uniq(faker.lorem.words().split(' '))), 11 | }) 12 | ))) 13 | )) 14 | ); 15 | -------------------------------------------------------------------------------- /src/model/Task.js: -------------------------------------------------------------------------------- 1 | import { ValueColumn, DateTimeColumn } from 'graphql-tower'; 2 | import { Model } from './Database'; 3 | import Project from './Project'; 4 | 5 | export default class Task extends Model { 6 | 7 | static tableName = 'task'; 8 | 9 | static columns = () => ({ 10 | user: new ValueColumn(String, 'userId'), 11 | project: new ValueColumn(Project, 'projectId'), 12 | note: new ValueColumn(), 13 | expiredAt: new DateTimeColumn(), 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /migrations/20171021163523_create-task.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | knex.schema.createTableIfNotExists('task', (table) => { 3 | table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v1mc()')); 4 | table.string('user_id'); 5 | table.string('note'); 6 | table.timestamps(); 7 | table.dateTime('deleted_at'); 8 | table.dateTime('expired_at').unsigned().index(); 9 | }) 10 | ); 11 | 12 | exports.down = knex => ( 13 | knex.schema.dropTable('task') 14 | ); 15 | -------------------------------------------------------------------------------- /migrations/20161008120007_create-table-project.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | knex.schema.createTable('project', (table) => { 3 | table.increments('id').primary(); 4 | table.string('title').notNullable(); 5 | table.text('description'); 6 | // website, github, hackfoldr, video 7 | table.json('detail'); 8 | table.timestamps(); 9 | table.dateTime('deleted_at'); 10 | }) 11 | ); 12 | 13 | exports.down = knex => ( 14 | knex.schema.dropTable('project') 15 | ); 16 | -------------------------------------------------------------------------------- /migrations/20171105145638_create-table-event.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | knex.schema.createTable('event', (table) => { 3 | table.increments('id').primary(); 4 | table.dateTime('datetime').index(); 5 | table.string('user_id').notNullable().index(); 6 | table.string('title'); 7 | table.jsonb('archive'); 8 | table.timestamps(); 9 | table.dateTime('deleted_at'); 10 | }) 11 | ); 12 | 13 | exports.down = knex => ( 14 | knex.schema.dropTable('event') 15 | ); 16 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/out.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`out command when g0ver have been a task 1`] = ` 4 | Array [ 5 | "select * from task where expired_at > DATE and deleted_at is null and user_id = U03B2AB13 limit 1", 6 | ] 7 | `; 8 | 9 | exports[`out command when g0ver no any task 1`] = ` 10 | Array [ 11 | "select * from task where expired_at > DATE and deleted_at is null and user_id = U03B2AB13 limit 1", 12 | ] 13 | `; 14 | -------------------------------------------------------------------------------- /src/command/search.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import G0ver from '../model/G0ver'; 3 | 4 | export default async function ({ keyword }) { 5 | if (!keyword) return null; 6 | 7 | const g0ver = new G0ver(); 8 | g0ver.search(_.toLower(keyword)); 9 | const ids = _.map(await g0ver.fetchAll(), ({ nativeId }) => `<@${nativeId}>`); 10 | 11 | if (_.size(ids) < 1) { 12 | return `搜尋 ${keyword} 找不到 g0ver, 也許你就是這樣沒有人.`; 13 | } 14 | 15 | return `搜尋 ${keyword} 找到了這些 g0ver: ${ids.join(', ')}.`; 16 | } 17 | -------------------------------------------------------------------------------- /src/command/del.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import G0ver from '../model/G0ver'; 3 | 4 | export default async function ({ hashtag }, { user }) { 5 | if (!hashtag) return null; 6 | const hashtags = _.map(_.split(hashtag, /[,,]/), value => _.trim(value, ' <>')); 7 | 8 | const g0ver = await G0ver.load(user) || await new G0ver({ id: user }).insert(); 9 | g0ver.skills = _.remove(g0ver.skills || [], value => (_.indexOf(hashtags, value) < 0)); 10 | await g0ver.save(); 11 | 12 | return `done it, del ${hashtag}.`; 13 | } 14 | -------------------------------------------------------------------------------- /src/query/G0verQuery.js: -------------------------------------------------------------------------------- 1 | import { GraphQLID } from 'graphql'; 2 | import queryWithConnection from '../help/queryWithConnection'; 3 | import GraphQLPrimary from '../type/GraphQLPrimary'; 4 | import GraphQLG0ver from '../type/GraphQLG0ver'; 5 | 6 | const { Connection, ...G0verQuery } = queryWithConnection({ 7 | type: GraphQLG0ver, 8 | args: { 9 | id: { type: GraphQLPrimary }, 10 | username: { type: GraphQLID }, 11 | }, 12 | resolve: async () => ({}), 13 | }); 14 | 15 | export default G0verQuery; 16 | export const G0verConnection = Connection; 17 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/search.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`search command when g0ver is existed 1`] = ` 4 | Array [ 5 | "select *, ts_rank(keyword, react) as rank from g0ver where keyword @@ to_tsquery(react) and deleted_at is null order by rank desc limit 1000", 6 | ] 7 | `; 8 | 9 | exports[`search command when g0ver not find 1`] = ` 10 | Array [ 11 | "select *, ts_rank(keyword, react) as rank from g0ver where keyword @@ to_tsquery(react) and deleted_at is null order by rank desc limit 1000", 12 | ] 13 | `; 14 | -------------------------------------------------------------------------------- /src/command/whois.js: -------------------------------------------------------------------------------- 1 | import G0ver from '../model/G0ver'; 2 | import Task from '../model/Task'; 3 | 4 | export default async function ({ user }) { 5 | const g0ver = await G0ver.load(user); 6 | 7 | const task = new Task({ user }); 8 | await task.where('expired_at', '>', new Date()).fetch(); 9 | 10 | const reply = ((g0ver && g0ver.skills) || []).join(', '); 11 | 12 | return [ 13 | `<@${user}>`, 14 | g0ver && g0ver.slogan, 15 | task.note ? `目前正在填 ${task.note} 坑` : '目前需要大家協助推坑', 16 | reply ? `已登錄的技能 ${reply}` : '需要大使協助指導登錄技能', 17 | ].join('\n'); 18 | } 19 | -------------------------------------------------------------------------------- /src/command/add.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import G0ver from '../model/G0ver'; 3 | 4 | export default async function ({ hashtag }, { user }) { 5 | if (!hashtag) return null; 6 | const hashtags = _.map(_.split(hashtag, /[,,]/), value => _.trim(value, ' <>')); 7 | 8 | const g0ver = await G0ver.load(user) || await new G0ver({ id: user }).insert(); 9 | 10 | g0ver.skills = _.concat( 11 | _.remove(g0ver.skills || [], value => (_.indexOf(hashtags, value) < 0)), 12 | hashtags, 13 | ); 14 | 15 | await g0ver.save(); 16 | 17 | return `done it, add ${hashtag}.`; 18 | } 19 | -------------------------------------------------------------------------------- /src/Schema.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLSchema } from 'graphql'; 2 | 3 | import { G0verConnection } from './query/G0verQuery'; 4 | 5 | import UpdateG0verMutation from './mutation/UpdateG0verMutation'; 6 | 7 | const Query = new GraphQLObjectType({ 8 | name: 'Query', 9 | fields: { 10 | g0ver: G0verConnection, 11 | }, 12 | }); 13 | 14 | const Mutation = new GraphQLObjectType({ 15 | name: 'Mutation', 16 | fields: { 17 | updateG0ver: UpdateG0verMutation, 18 | }, 19 | }); 20 | 21 | export default new GraphQLSchema({ 22 | query: Query, 23 | mutation: Mutation, 24 | }); 25 | -------------------------------------------------------------------------------- /src/command/projects.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Project from '../model/Project'; 3 | 4 | export default async function () { 5 | const query = new Project(); 6 | const projects = await query.fetchAll(); 7 | return { 8 | text: '下列專案歡迎你的加入:', 9 | attachments: _.map(projects, project => ({ 10 | color: '#000', 11 | mrkdwn_in: ['text', 'pretext', 'fields'], 12 | thumb_url: project.thumb, 13 | title: project.title, 14 | title_link: project.url, 15 | text: _.filter([`坑主: <@${project.user}>`, project.tags]).join('\n'), 16 | })), 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/command/whoami.js: -------------------------------------------------------------------------------- 1 | import G0ver from '../model/G0ver'; 2 | import Task from '../model/Task'; 3 | 4 | export default async function (match, { user, name }) { 5 | const g0ver = await G0ver.load(user); 6 | 7 | const task = new Task({ user }); 8 | await task.where('expired_at', '>', new Date()).fetch(); 9 | 10 | const reply = ((g0ver && g0ver.skills) || []).join(', '); 11 | 12 | return [ 13 | name, 14 | g0ver && g0ver.slogan, 15 | task.note ? `目前正在填 ${task.note} 坑` : '尚未入坑,歡迎尋找 g0v 大使的協助', 16 | reply ? `已登錄的技能 ${reply}` : '請使用 `add ` 新增你的技能', 17 | ].join('\n'); 18 | } 19 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/jothon.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`jothon command when event does not exist 1`] = ` 4 | Array [ 5 | "select * from event where deleted_at is null and title = 基礎建設松 limit 1", 6 | "insert into event (archive, created_at, datetime, title, updated_at, user_id) values ({\\"url\\":\\"https://events.g0v.tw/\\"}, DATE, DATE, 基礎建設松, DATE, U03B2AB13) returning id", 7 | ] 8 | `; 9 | 10 | exports[`jothon command when event is existed 1`] = ` 11 | Array [ 12 | "select * from event where deleted_at is null and title = 基礎建設松 limit 1", 13 | ] 14 | `; 15 | -------------------------------------------------------------------------------- /src/model/G0ver.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { ValueColumn, ListColumn } from 'graphql-tower'; 3 | import { Model } from './Database'; 4 | import GraphQLG0ver from '../type/GraphQLG0ver'; 5 | 6 | export default class G0ver extends Model { 7 | 8 | static tableName = 'g0ver'; 9 | 10 | static columns = () => ({ 11 | username: new ValueColumn(), 12 | channel: new ValueColumn(), 13 | skills: new ListColumn(), 14 | slogan: new ValueColumn(), 15 | }) 16 | 17 | static toKeyword({ skills }) { 18 | return _.toLower((skills || []).join(' ')); 19 | } 20 | 21 | type = GraphQLG0ver; 22 | } 23 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/del.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`del command when g0ver does not exist 1`] = ` 4 | Array [ 5 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 6 | "insert into g0ver (created_at, id, keyword, updated_at) values (DATE, U03B2AB13, , DATE) returning id", 7 | "update g0ver set keyword = , skills = [], updated_at = DATE where deleted_at is null and id = U03B2AB13", 8 | ] 9 | `; 10 | 11 | exports[`del command when g0ver is existed 1`] = ` 12 | Array [ 13 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 14 | ] 15 | `; 16 | -------------------------------------------------------------------------------- /src/command/events.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Event from '../model/Event'; 3 | 4 | export default async function () { 5 | const query = new Event(); 6 | const events = await query.whereBefore().fetchAll(); 7 | return { 8 | text: '下列活動歡迎你的參加:', 9 | attachments: _.map(events, event => ({ 10 | color: '#000', 11 | mrkdwn_in: ['text', 'pretext', 'fields'], 12 | title: event.title, 13 | title_link: event.url, 14 | text: [ 15 | ``, 16 | `聯絡人: <@${event.user}>`, 17 | ].join('\n'), 18 | })), 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /seeds/project.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker'); 2 | const _ = require('lodash'); 3 | 4 | exports.seed = (knex, Promise) => ( 5 | knex('project').truncate() 6 | .then(() => ( 7 | Promise.all(_.range(1, 100).map(() => ( 8 | knex('project').insert({ 9 | title: faker.hacker.adjective(), 10 | description: faker.lorem.lines(), 11 | detail: JSON.stringify({ 12 | website: faker.internet.url(), 13 | github: faker.internet.url(), 14 | hackfoldr: faker.internet.url(), 15 | video: faker.internet.url(), 16 | }), 17 | }) 18 | ))) 19 | )) 20 | ); 21 | -------------------------------------------------------------------------------- /src/type/GraphQLPrimary.js: -------------------------------------------------------------------------------- 1 | import { Kind, GraphQLScalarType, GraphQLError } from 'graphql'; 2 | import _ from 'lodash'; 3 | 4 | export default new GraphQLScalarType({ 5 | name: 'Primary', 6 | serialize: value => _.parseInt(value), 7 | parseValue: value => _.parseInt(value), 8 | parseLiteral: (ast) => { 9 | if (ast.kind !== Kind.STRING && ast.kind !== Kind.INT) { 10 | throw new GraphQLError(`Can only parse string or int got a: ${ast.kind}`, [ast]); 11 | } 12 | 13 | const value = _.parseInt(ast.value); 14 | if (_.isNaN(value) || value < 1) { 15 | throw new GraphQLError('Not a valid PrimaryType', [ast]); 16 | } 17 | 18 | return value; 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/type/GraphQLProject.js: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 2 | import GraphQLJsonField from './GraphQLJsonField'; 3 | import GraphQLPrimary from './GraphQLPrimary'; 4 | import G0verQuery from '../query/G0verQuery'; 5 | 6 | export default new GraphQLObjectType({ 7 | name: 'Project', 8 | fields: () => ({ 9 | id: { type: GraphQLPrimary }, 10 | title: { type: GraphQLString }, 11 | description: { type: GraphQLString }, 12 | website: new GraphQLJsonField(GraphQLString), 13 | github: new GraphQLJsonField(GraphQLString), 14 | hackfoldr: new GraphQLJsonField(GraphQLString), 15 | video: new GraphQLJsonField(GraphQLString), 16 | g0ver: G0verQuery, 17 | }), 18 | }); 19 | -------------------------------------------------------------------------------- /src/command/__tests__/all.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('all command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'all' }; 7 | 8 | it('when g0ver have been tasks', async () => { 9 | client.mockReturnValueOnce([ 10 | { id: 'XYZ1', user_id: 'U03B2AB13', note: 'g0ver box' }, 11 | { id: 'XYZ2', user_id: 'U0RQYV16K', note: 'harmonica' }, 12 | ]); 13 | await index(data); 14 | expect(client).toMatchSnapshot(); 15 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: '<@U03B2AB13> in g0ver box\n<@U0RQYV16K> in harmonica' }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /script/migration-schema.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { graphql } from 'graphql'; 4 | import { introspectionQuery, printSchema } from 'graphql/utilities'; 5 | import schema from '../src/Schema'; 6 | 7 | (async () => { 8 | const result = await (graphql(schema, introspectionQuery)); 9 | if (result.errors) { 10 | console.error( 11 | 'ERROR introspecting schema: ', 12 | JSON.stringify(result.errors, null, 2) 13 | ); 14 | } else { 15 | fs.writeFileSync( 16 | path.join(__dirname, '../schema.json'), 17 | JSON.stringify(result, null, 2) 18 | ); 19 | 20 | process.exit(); 21 | } 22 | })(); 23 | 24 | fs.writeFileSync( 25 | path.join(__dirname, '../schema.graphql'), 26 | printSchema(schema) 27 | ); 28 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/slogan.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`add command when g0ver does not exist 1`] = ` 4 | Array [ 5 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 6 | "insert into g0ver (created_at, id, keyword, updated_at) values (DATE, U03B2AB13, , DATE) returning id", 7 | "update g0ver set keyword = , slogan = are you freestyle, updated_at = DATE where deleted_at is null and id = U03B2AB13", 8 | ] 9 | `; 10 | 11 | exports[`add command when g0ver is existed 1`] = ` 12 | Array [ 13 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 14 | "update g0ver set keyword = , slogan = 你就是沒有人, updated_at = DATE where deleted_at is null and id = U03B2AB13", 15 | ] 16 | `; 17 | -------------------------------------------------------------------------------- /src/help/queryWithConnection.js: -------------------------------------------------------------------------------- 1 | import { GraphQLList } from 'graphql'; 2 | import { connectionArgs, connectionDefinitions, connectionFromArray } from 'graphql-relay'; 3 | 4 | export default function queryWithConnection(config) { 5 | const { connectionType } = connectionDefinitions({ 6 | name: config.type.name, 7 | nodeType: config.type, 8 | }); 9 | 10 | return { 11 | ...config, 12 | type: new GraphQLList(config.type), 13 | Connection: { 14 | ...config, 15 | type: connectionType, 16 | args: { ...config.args, ...connectionArgs }, 17 | resolve: async function resolve(...args) { 18 | const result = await config.resolve(...args); 19 | return connectionFromArray(result, args[1]); 20 | }, 21 | }, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/command/__tests__/out.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('out command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'out' }; 7 | 8 | it('when g0ver no any task', async () => { 9 | client.mockReturnValueOnce([]); 10 | await index(data); 11 | expect(client).toMatchSnapshot(); 12 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'yutin 目前沒有入坑' }); 13 | }); 14 | 15 | it('when g0ver have been a task', async () => { 16 | client.mockReturnValueOnce([{ id: 'XYZ', note: 'g0ver box' }]); 17 | await index(data); 18 | expect(client).toMatchSnapshot(); 19 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'yutin 跟大家分享 g0ver box 吧!' }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/model/Project.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { ValueColumn, ArchiveColumn, ListColumn } from 'graphql-tower'; 3 | import { Model } from './Database'; 4 | 5 | export default class Project extends Model { 6 | 7 | static tableName = 'project'; 8 | 9 | static columns = () => ({ 10 | user: new ValueColumn(String, 'userId'), 11 | title: new ValueColumn(), 12 | tags: new ArchiveColumn(), 13 | url: new ArchiveColumn(), 14 | thumb: new ArchiveColumn(), 15 | follower: new ListColumn(String, 'followerIds'), 16 | }) 17 | 18 | static toKeyword({ archive }) { 19 | return _.toLower(archive && archive.tags); 20 | } 21 | 22 | whereFollowed(user) { 23 | return this.whereRaw('follower_ids @> ?', [[user]]); 24 | } 25 | 26 | whereUnfollowed(user) { 27 | return this.whereRaw('follower_ids <> ?', [[user]]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/command/__tests__/whoami.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('whoami command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'whoami' }; 7 | 8 | it('when g0ver does not exist', async () => { 9 | client.mockReturnValueOnce([]); 10 | client.mockReturnValueOnce([]); 11 | await index(data); 12 | expect(client).toMatchSnapshot(); 13 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 14 | }); 15 | 16 | it('when g0ver is existed', async () => { 17 | client.mockReturnValueOnce([{ id: 'U03B2AB13', skills: ['react', 'video'] }]); 18 | client.mockReturnValueOnce([]); 19 | await index(data); 20 | expect(client).toMatchSnapshot(); 21 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/command/__tests__/whois.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('whois command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'whois <@U0384RCFD>' }; 7 | 8 | it('when g0ver does not exist', async () => { 9 | client.mockReturnValueOnce([]); 10 | client.mockReturnValueOnce([]); 11 | await index(data); 12 | expect(client).toMatchSnapshot(); 13 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 14 | }); 15 | 16 | it('when g0ver is existed', async () => { 17 | client.mockReturnValueOnce([{ id: 'U0384RCFD', skills: ['react', 'harmonica'] }]); 18 | client.mockReturnValueOnce([]); 19 | await index(data); 20 | expect(client).toMatchSnapshot(); 21 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/model/Event.js: -------------------------------------------------------------------------------- 1 | import { ValueColumn, ArchiveColumn, DateTimeColumn, ListColumn } from 'graphql-tower'; 2 | import { Model } from './Database'; 3 | 4 | export default class Event extends Model { 5 | 6 | static tableName = 'event'; 7 | 8 | static columns = () => ({ 9 | user: new ValueColumn(String, 'userId'), 10 | title: new ValueColumn(), 11 | datetime: new DateTimeColumn(), 12 | url: new ArchiveColumn(), 13 | follower: new ListColumn(String, 'followerIds'), 14 | }) 15 | 16 | whereBefore() { 17 | const { database } = this.constructor; 18 | this.where('datetime', '>', database.raw('NOW()')); 19 | this.orderBy('datetime'); 20 | return this; 21 | } 22 | 23 | whereFollowed(user) { 24 | return this.whereRaw('follower_ids @> ?', [[user]]); 25 | } 26 | 27 | whereUnfollowed(user) { 28 | return this.whereRaw('follower_ids <> ?', [[user]]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/mutation/UpdateG0verMutation.js: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull, GraphQLList, GraphQLString } from 'graphql'; 2 | import { mutationWithClientMutationId } from 'graphql-relay'; 3 | import GraphQLG0ver from '../type/GraphQLG0ver'; 4 | import G0ver from '../model/G0ver'; 5 | 6 | export default mutationWithClientMutationId({ 7 | name: 'UpdateG0ver', 8 | inputFields: { 9 | username: { type: new GraphQLNonNull(GraphQLString) }, 10 | skill: { type: new GraphQLList(GraphQLString) }, 11 | }, 12 | outputFields: { 13 | g0ver: { type: GraphQLG0ver }, 14 | }, 15 | mutateAndGetPayload: async ({ username, skill }) => { 16 | const g0ver = await new G0ver().where({ username }).fetch(); 17 | const model = g0ver || new G0ver({ username }); 18 | 19 | if (skill) model.set('skill', JSON.stringify(skill)); 20 | await model.save(); 21 | 22 | return { g0ver: model.toJSON() }; 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /migrations/20171105172543_add-followers.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | Promise.resolve() 3 | .then(() => knex.schema.table('project', (table) => { 4 | table.specificType('follower_ids', 'text[]').defaultTo('{}'); 5 | })) 6 | .then(() => knex.schema.raw( 7 | 'CREATE INDEX project_follower_ids_idx ON project USING GIN (follower_ids);' 8 | )) 9 | .then(() => knex.schema.table('event', (table) => { 10 | table.specificType('follower_ids', 'text[]').defaultTo('{}'); 11 | })) 12 | .then(() => knex.schema.raw( 13 | 'CREATE INDEX event_follower_ids_idx ON project USING GIN (follower_ids);' 14 | )) 15 | ); 16 | 17 | exports.down = knex => ( 18 | Promise.resolve() 19 | .then(() => knex.schema.table('project', (table) => { 20 | table.dropColumn('follower_ids'); 21 | })) 22 | .then(() => knex.schema.table('event', (table) => { 23 | table.dropColumn('follower_ids'); 24 | })) 25 | ); 26 | -------------------------------------------------------------------------------- /src/type/GraphQLJsonField.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: ["error", { "props": false }] */ 2 | 3 | import { isType } from 'graphql'; 4 | import _ from 'lodash'; 5 | import camelgetter from '../help/camelgetter'; 6 | 7 | export default class GraphQLJsonType { 8 | 9 | type = null; 10 | 11 | field = null; 12 | 13 | constructor(config) { 14 | this.type = _.get(config, 'type', config); 15 | this.field = _.get(config, 'field', 'detail'); 16 | if (!isType(this.type)) { 17 | throw new Error(`Can only create json field of a GraphQLType but got: ${String(this.type)}.`); 18 | } 19 | } 20 | 21 | resolve = (payload, args, context, { fieldName }) => { 22 | const field = this.field; 23 | payload[field] = camelgetter(payload, field) || {}; 24 | 25 | if (_.isString(payload[field])) { 26 | payload[field] = JSON.parse(payload[field]); 27 | } 28 | 29 | return payload[field][fieldName] || null; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /migrations/20171021134832_redesign-g0ver.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | Promise.resolve() 3 | .then(() => knex.schema.dropTable('g0ver')) 4 | .then(() => knex.schema.createTable('g0ver', (table) => { 5 | table.string('id').primary(); 6 | table.specificType('skills', 'text[]'); 7 | table.specificType('keyword', 'tsvector'); 8 | table.timestamps(); 9 | table.dateTime('deleted_at'); 10 | })) 11 | .then(() => ( 12 | knex.schema.raw('CREATE INDEX g0ver_keyword_idx ON g0ver USING GIN (keyword);') 13 | )) 14 | ); 15 | 16 | exports.down = knex => ( 17 | Promise.resolve() 18 | .then(() => knex.schema.dropTable('g0ver')) 19 | .then(() => knex.schema.createTable('g0ver', (table) => { 20 | table.increments('id').primary(); 21 | table.string('username').notNullable().index(); 22 | table.json('skill'); 23 | table.timestamps(); 24 | table.dateTime('deleted_at'); 25 | })) 26 | ); 27 | -------------------------------------------------------------------------------- /src/command/__tests__/search.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('search command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'search react' }; 7 | 8 | it('when g0ver not find', async () => { 9 | client.mockReturnValueOnce([]); 10 | await index(data); 11 | expect(client).toMatchSnapshot(); 12 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: '搜尋 react 找不到 g0ver, 也許你就是這樣沒有人.' }); 13 | }); 14 | 15 | it('when g0ver is existed', async () => { 16 | client.mockReturnValueOnce([ 17 | { id: 'U03B2AB13', skills: ['react', 'video'] }, 18 | { id: 'U0RQYV16K', skills: ['react', 'math'] }, 19 | ]); 20 | await index(data); 21 | expect(client).toMatchSnapshot(); 22 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: '搜尋 react 找到了這些 g0ver: <@U03B2AB13>, <@U0RQYV16K>.' }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/command/__tests__/del.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('del command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'del react' }; 7 | 8 | it('when g0ver does not exist', async () => { 9 | client.mockReturnValueOnce([]); 10 | client.mockReturnValueOnce(['U03B2AB13']); 11 | client.mockReturnValueOnce([]); 12 | await index(data); 13 | expect(client).toMatchSnapshot(); 14 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'done it, del react.' }); 15 | }); 16 | 17 | it('when g0ver is existed', async () => { 18 | client.mockReturnValueOnce([{ id: 'U03B2AB13', skills: [] }]); 19 | client.mockReturnValueOnce([]); 20 | await index({ ...data, text: 'del video' }); 21 | expect(client).toMatchSnapshot(); 22 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'done it, del video.' }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /migrations/20171105010345_update-project-table.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => ( 2 | Promise.resolve() 3 | .then(() => knex.schema.table('project', (table) => { 4 | table.string('user_id'); 5 | table.string('tags'); 6 | table.specificType('keyword', 'tsvector'); 7 | table.renameColumn('detail', 'archive'); 8 | table.dropColumn('description'); 9 | })) 10 | .then(() => knex.schema.raw( 11 | 'CREATE INDEX project_keyword_idx ON project USING GIN (keyword);' 12 | )) 13 | .then(() => knex.schema.table('task', (table) => { 14 | table.integer('project_id'); 15 | })) 16 | ); 17 | 18 | exports.down = knex => ( 19 | Promise.resolve() 20 | .then(() => knex.schema.table('project', (table) => { 21 | table.dropColumn('user_id'); 22 | table.dropColumn('tags'); 23 | table.dropColumn('keyword'); 24 | table.renameColumn('archive', 'detail'); 25 | })) 26 | .then(() => knex.schema.table('task', (table) => { 27 | table.dropColumn('project_id'); 28 | })) 29 | ); 30 | -------------------------------------------------------------------------------- /src/command/__tests__/slogan.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('add command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'slogan are you freestyle' }; 7 | 8 | it('when g0ver does not exist', async () => { 9 | client.mockReturnValueOnce([]); 10 | client.mockReturnValueOnce(['U03B2AB13']); 11 | client.mockReturnValueOnce([]); 12 | await index(data); 13 | expect(client).toMatchSnapshot(); 14 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'done it, setting are you freestyle.' }); 15 | }); 16 | 17 | it('when g0ver is existed', async () => { 18 | client.mockReturnValueOnce([{ id: 'U03B2AB13' }]); 19 | client.mockReturnValueOnce([]); 20 | await index({ ...data, text: 'slogan 你就是沒有人' }); 21 | expect(client).toMatchSnapshot(); 22 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'done it, setting 你就是沒有人.' }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/add.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`add command when g0ver does not exist 1`] = ` 4 | Array [ 5 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 6 | "insert into g0ver (created_at, id, keyword, updated_at) values (DATE, U03B2AB13, , DATE) returning id", 7 | "update g0ver set keyword = react, skills = [react], updated_at = DATE where deleted_at is null and id = U03B2AB13", 8 | ] 9 | `; 10 | 11 | exports[`add command when g0ver is existed 1`] = ` 12 | Array [ 13 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 14 | "update g0ver set keyword = video, skills = [video], updated_at = DATE where deleted_at is null and id = U03B2AB13", 15 | ] 16 | `; 17 | 18 | exports[`add command when g0ver skill is existed 1`] = ` 19 | Array [ 20 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 21 | "update g0ver set keyword = video rails, skills = [video,rails], updated_at = DATE where deleted_at is null and id = U03B2AB13", 22 | ] 23 | `; 24 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/create.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`create command when project does not exist 1`] = ` 4 | Array [ 5 | "select * from project where deleted_at is null and title = g0ver-box limit 1", 6 | "insert into project (archive, created_at, keyword, title, updated_at, user_id) values ({\\"url\\":\\"https://project.g0v.tw/\\",\\"tags\\":\\"找人 找坑\\",\\"thumb\\":\\"https://project.g0v.tw/index.png\\"}, DATE, 找人 找坑, g0ver-box, DATE, U03B2AB13) returning id", 7 | ] 8 | `; 9 | 10 | exports[`create command when project is existed 1`] = ` 11 | Array [ 12 | "select * from project where deleted_at is null and title = g0ver-box limit 1", 13 | ] 14 | `; 15 | 16 | exports[`create command when use skip 1`] = ` 17 | Array [ 18 | "select * from project where deleted_at is null and title = g0ver-box limit 1", 19 | "insert into project (archive, created_at, keyword, title, updated_at, user_id) values ({\\"url\\":null,\\"tags\\":null,\\"thumb\\":null}, DATE, , g0ver-box, DATE, U03B2AB13) returning id", 20 | ] 21 | `; 22 | -------------------------------------------------------------------------------- /src/help/sqlparser.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export default function sqlparser(...args) { 4 | let omit = args[2] || ['created_at', 'updated_at']; 5 | let number = null; 6 | if (_.isInteger(args[1])) { 7 | number = args[1]; 8 | } else if (_.isArray(args[1])) { 9 | omit = args[1]; 10 | } 11 | 12 | let req = args[0]; 13 | if (jest && jest.isMockFunction(args[0])) { 14 | const calls = _.get(args[0], 'mock.calls', []); 15 | req = _.get(calls, `[${_.isNil(number) ? calls.length - 1 : number}][1]`, {}); 16 | } 17 | 18 | const regex = /^insert /i.test(req.sql) ? /"[\w_-]+"[,)]/gi : /"?([\w_]+)"?[ =]+\?/gi; 19 | const keys = _.map(req.sql.match(regex), key => /([\w_]+)/gi.exec(key)[1]); 20 | const param = _.mapValues( 21 | _.invert(keys), 22 | (index, key) => (_.includes(omit, key) ? !!req.bindings[index] : req.bindings[index]), 23 | ); 24 | 25 | const table = /(insert into|update|from) "([^"]+)"/i.exec(req.sql); 26 | 27 | return { 28 | ...param, 29 | table: table && table[2], 30 | method: req.method, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/Slack.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import slack from 'slack'; 3 | 4 | export const bot = slack.rtm.client(); 5 | 6 | const TOKEN = process.env.SLACK_TOKEN; 7 | 8 | if (TOKEN) { 9 | bot.listen({ token: TOKEN }); 10 | } 11 | 12 | bot.hello((message) => { 13 | console.log(`Got a message: ${message.type}`); 14 | }); 15 | 16 | const call = (name, req = {}) => ( 17 | new Promise((resolve, reject) => { 18 | const fn = _.get(slack, name); 19 | fn( 20 | { ...req, token: TOKEN }, 21 | (err, res) => (err ? reject(err) : resolve(res)) 22 | ); 23 | }) 24 | ); 25 | 26 | export default { 27 | userInfo: async req => await call('users.info', req), 28 | postMessage: async req => await call('chat.postMessage', { ...req, as_user: true }), 29 | postMultiMessage: async (users, message) => _.map(users, user => bot.ws.send(JSON.stringify({ 30 | id: `${user}::${Date.now()}`, type: 'message', channel: user, text: message, 31 | }))), 32 | channelInfo: async req => await call('channels.info', req), 33 | channelJoin: async req => await call('channels.join', req), 34 | }; 35 | -------------------------------------------------------------------------------- /src/help/__tests__/camelgetter.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import faker from 'faker'; 3 | import camelgetter from '../camelgetter'; 4 | 5 | describe('camelgetter help', () => { 6 | it('when name is snakeCase', async () => { 7 | const key = _.camelCase(`${faker.lorem.word()}_${faker.lorem.word()}`); 8 | const value = _.random(1, 9999); 9 | expect(camelgetter(_.set({}, _.snakeCase(key), value), key)).toBe(value); 10 | }); 11 | 12 | it('when name is camelCase', async () => { 13 | const key = _.camelCase(`${faker.lorem.word()}_${faker.lorem.word()}`); 14 | const value = _.random(1, 9999); 15 | expect(camelgetter(_.set({}, key, value), key)).toBe(value); 16 | }); 17 | 18 | it('when value is undefined', async () => { 19 | const key = _.camelCase(`${faker.lorem.word()}_${faker.lorem.word()}`); 20 | expect(camelgetter({}, key)).toBeUndefined(); 21 | }); 22 | 23 | it('when data is undefined', async () => { 24 | const key = _.camelCase(`${faker.lorem.word()}_${faker.lorem.word()}`); 25 | expect(camelgetter(null, key)).toBeUndefined(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/whois.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`whois command when g0ver does not exist 1`] = ` 4 | Array [ 5 | "select * from g0ver where id in (U0384RCFD) and deleted_at is null", 6 | "select * from task where expired_at > DATE and deleted_at is null and user_id = U0384RCFD limit 1", 7 | ] 8 | `; 9 | 10 | exports[`whois command when g0ver does not exist 2`] = ` 11 | Array [ 12 | Array [ 13 | Object { 14 | "channel": "D100", 15 | "text": "<@U0384RCFD> 16 | 17 | 目前需要大家協助推坑 18 | 需要大使協助指導登錄技能", 19 | }, 20 | ], 21 | ] 22 | `; 23 | 24 | exports[`whois command when g0ver is existed 1`] = ` 25 | Array [ 26 | "select * from g0ver where id in (U0384RCFD) and deleted_at is null", 27 | "select * from task where expired_at > DATE and deleted_at is null and user_id = U0384RCFD limit 1", 28 | ] 29 | `; 30 | 31 | exports[`whois command when g0ver is existed 2`] = ` 32 | Array [ 33 | Array [ 34 | Object { 35 | "channel": "D100", 36 | "text": "<@U0384RCFD> 37 | 38 | 目前需要大家協助推坑 39 | 已登錄的技能 react, harmonica", 40 | }, 41 | ], 42 | ] 43 | `; 44 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/whoami.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`whoami command when g0ver does not exist 1`] = ` 4 | Array [ 5 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 6 | "select * from task where expired_at > DATE and deleted_at is null and user_id = U03B2AB13 limit 1", 7 | ] 8 | `; 9 | 10 | exports[`whoami command when g0ver does not exist 2`] = ` 11 | Array [ 12 | Array [ 13 | Object { 14 | "channel": "D100", 15 | "text": "yutin 16 | 17 | 尚未入坑,歡迎尋找 g0v 大使的協助 18 | 請使用 \`add \` 新增你的技能", 19 | }, 20 | ], 21 | ] 22 | `; 23 | 24 | exports[`whoami command when g0ver is existed 1`] = ` 25 | Array [ 26 | "select * from g0ver where id in (U03B2AB13) and deleted_at is null", 27 | "select * from task where expired_at > DATE and deleted_at is null and user_id = U03B2AB13 limit 1", 28 | ] 29 | `; 30 | 31 | exports[`whoami command when g0ver is existed 2`] = ` 32 | Array [ 33 | Array [ 34 | Object { 35 | "channel": "D100", 36 | "text": "yutin 37 | 38 | 尚未入坑,歡迎尋找 g0v 大使的協助 39 | 已登錄的技能 react, video", 40 | }, 41 | ], 42 | ] 43 | `; 44 | -------------------------------------------------------------------------------- /src/command/cancel.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Event from '../model/Event'; 3 | import Interaction from '../model/Interaction'; 4 | 5 | async function inputEvent(user) { 6 | const query = new Event({ user }); 7 | const events = await query.fetchAll(); 8 | 9 | if (events.length < 1) return 'Sorry! 找不到活動,快來揪松吧'; 10 | 11 | Interaction.set(user, async ({ text }) => { 12 | const index = _.trim(text); 13 | const event = events[index - 1]; 14 | if (!event) return 'Sorry! 找不到活動'; 15 | 16 | await event.destroy(); 17 | return `Done, 取消 ${event.title} 活動`; 18 | }); 19 | 20 | return [ 21 | '請選擇要取消的活動(輸入代碼)?\n`(輸入 exit 可離開)`', 22 | _.map(events, (event, idx) => `*${idx + 1}*. ${event.title}`).join('\n'), 23 | ].join('\n'); 24 | } 25 | 26 | export default async function ({ value }, { user }) { 27 | const title = _.trim(value); 28 | if (title) { 29 | const event = await (new Event({ title })).fetch(); 30 | if (!event) return 'Sorry! 找不到活動'; 31 | if (event.user !== user) return `Sorry! 沒有權限取消 ${title} 活動`; 32 | 33 | await event.destroy(); 34 | return `Done, 取消 ${title} 活動`; 35 | } 36 | 37 | return inputEvent(user); 38 | } 39 | -------------------------------------------------------------------------------- /src/command/remove.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Project from '../model/Project'; 3 | import Interaction from '../model/Interaction'; 4 | 5 | async function inputProject(user) { 6 | const query = new Project({ user }); 7 | const projects = await query.fetchAll(); 8 | 9 | if (projects.length < 1) return 'Sorry! 找不到坑,快來挖坑吧'; 10 | 11 | Interaction.set(user, async ({ text }) => { 12 | const index = _.trim(text); 13 | const project = projects[index - 1]; 14 | if (!project) return 'Sorry! 找不到專案'; 15 | 16 | await project.destroy(); 17 | return `Done, 刪除 ${project.title} 專案`; 18 | }); 19 | 20 | return [ 21 | '請選擇要刪除的專案(輸入代碼)?\n`(輸入 exit 可離開)`', 22 | _.map(projects, (project, idx) => `*${idx + 1}*. ${project.title}`).join('\n'), 23 | ].join('\n'); 24 | } 25 | 26 | export default async function ({ value }, { user }) { 27 | const title = _.trim(value); 28 | if (title) { 29 | const project = await (new Project({ title })).fetch(); 30 | if (!project) return 'Sorry! 找不到專案'; 31 | if (project.user !== user) return `Sorry! 沒有權限刪除 ${title} 專案`; 32 | 33 | await project.destroy(); 34 | return `Done, 刪除 ${title} 專案`; 35 | } 36 | 37 | return inputProject(user); 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | .AppleDouble 3 | .LSOverride 4 | 5 | # Icon must end with two \r 6 | Icon 7 | 8 | # Thumbnails 9 | ._* 10 | 11 | # Files that might appear in the root of a volume 12 | .DocumentRevisions-V100 13 | .fseventsd 14 | .Spotlight-V100 15 | .TemporaryItems 16 | .Trashes 17 | .VolumeIcon.icns 18 | .com.apple.timemachine.donotpresent 19 | 20 | # Directories potentially created on remote AFP share 21 | .AppleDB 22 | .AppleDesktop 23 | Network Trash Folder 24 | Temporary Items 25 | .apdisk 26 | 27 | # Logs 28 | logs 29 | *.log 30 | npm-debug.log* 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # node-waf configuration 51 | .lock-wscript 52 | 53 | # Compiled binary addons (http://nodejs.org/api/addons.html) 54 | build/Release 55 | 56 | # Dependency directories 57 | node_modules 58 | jspm_packages 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | -------------------------------------------------------------------------------- /src/command/__tests__/add.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('add command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'add react' }; 7 | 8 | it('when g0ver does not exist', async () => { 9 | client.mockReturnValueOnce([]); 10 | client.mockReturnValueOnce(['U03B2AB13']); 11 | client.mockReturnValueOnce([]); 12 | await index(data); 13 | expect(client).toMatchSnapshot(); 14 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'done it, add react.' }); 15 | }); 16 | 17 | it('when g0ver is existed', async () => { 18 | client.mockReturnValueOnce([{ id: 'U03B2AB13' }]); 19 | client.mockReturnValueOnce([]); 20 | await index({ ...data, text: 'add video' }); 21 | expect(client).toMatchSnapshot(); 22 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'done it, add video.' }); 23 | }); 24 | 25 | it('when g0ver skill is existed', async () => { 26 | client.mockReturnValueOnce([{ id: 'U03B2AB13', skills: ['video'] }]); 27 | client.mockReturnValueOnce([]); 28 | await index({ ...data, text: 'add video, rails' }); 29 | expect(client).toMatchSnapshot(); 30 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ channel: 'D100', text: 'done it, add video, rails.' }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/cancel.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`cancel command cancel permission denied 1`] = ` 4 | Array [ 5 | "select * from event where deleted_at is null and title = 基礎建設松 limit 1", 6 | ] 7 | `; 8 | 9 | exports[`cancel command cancel successfully deleted 1`] = ` 10 | Array [ 11 | "select * from event where deleted_at is null and title = 基礎建設松 limit 1", 12 | "update event set deleted_at = DATE where deleted_at is null and id = 5", 13 | ] 14 | `; 15 | 16 | exports[`cancel command cancel when event does not exist 1`] = ` 17 | Array [ 18 | "select * from event where deleted_at is null and title = 基礎建設松 limit 1", 19 | ] 20 | `; 21 | 22 | exports[`cancel command remove [name] when event does not exist 1`] = ` 23 | Array [ 24 | "select * from event where deleted_at is null and user_id = U03B2AB13 limit 1000", 25 | ] 26 | `; 27 | 28 | exports[`cancel command remove [name] when event is existed successfully deleted 1`] = ` 29 | Array [ 30 | "select * from event where deleted_at is null and user_id = U03B2AB13 limit 1000", 31 | "update event set deleted_at = DATE where deleted_at is null and id = 3", 32 | ] 33 | `; 34 | 35 | exports[`cancel command remove [name] when event is existed when not found event 1`] = ` 36 | Array [ 37 | "select * from event where deleted_at is null and user_id = U03B2AB13 limit 1000", 38 | ] 39 | `; 40 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/remove.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`remove command remove permission denied 1`] = ` 4 | Array [ 5 | "select * from project where deleted_at is null and title = g0ver box limit 1", 6 | ] 7 | `; 8 | 9 | exports[`remove command remove <title> successfully deleted 1`] = ` 10 | Array [ 11 | "select * from project where deleted_at is null and title = g0ver box limit 1", 12 | "update project set deleted_at = DATE where deleted_at is null and id = 5", 13 | ] 14 | `; 15 | 16 | exports[`remove command remove <title> when project does not exist 1`] = ` 17 | Array [ 18 | "select * from project where deleted_at is null and title = g0ver box limit 1", 19 | ] 20 | `; 21 | 22 | exports[`remove command remove [title] when project does not exist 1`] = ` 23 | Array [ 24 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 25 | ] 26 | `; 27 | 28 | exports[`remove command remove [title] when project is existed successfully deleted 1`] = ` 29 | Array [ 30 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 31 | "update project set deleted_at = DATE where deleted_at is null and id = 3", 32 | ] 33 | `; 34 | 35 | exports[`remove command remove [title] when project is existed when not found project 1`] = ` 36 | Array [ 37 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 38 | ] 39 | `; 40 | -------------------------------------------------------------------------------- /src/type/__tests__/GraphQLPrimary.test.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import faker from 'faker'; 3 | import { Kind } from 'graphql'; 4 | import GraphQLPrimary from '../GraphQLPrimary'; 5 | 6 | const primary = _.random(1, 999); 7 | 8 | describe('GraphQLPrimary Type', () => { 9 | it('serialize', async () => { 10 | expect(GraphQLPrimary.serialize(primary)).toBe(primary); 11 | expect(GraphQLPrimary.serialize(`${primary}`)).toBe(primary); 12 | }); 13 | 14 | it('parseValue', async () => { 15 | expect(GraphQLPrimary.parseValue(primary)).toBe(primary); 16 | expect(GraphQLPrimary.parseValue(`${primary}`)).toBe(primary); 17 | }); 18 | 19 | it('parseLiteral', async () => { 20 | expect(GraphQLPrimary.parseLiteral({ 21 | kind: Kind.STRING, 22 | value: `${primary}`, 23 | loc: { start: 0, end: 10 }, 24 | })).toBe(primary); 25 | 26 | expect(GraphQLPrimary.parseLiteral({ 27 | kind: Kind.INT, 28 | value: primary, 29 | loc: { start: 0, end: 10 }, 30 | })).toBe(primary); 31 | 32 | const kind = faker.random.objectElement(_.omit(Kind, ['STRING', 'INT'])); 33 | expect(() => GraphQLPrimary.parseLiteral({ 34 | kind, 35 | value: `${primary}`, 36 | loc: { start: 0, end: 10 }, 37 | })).toThrowError(`Can only parse string or int got a: ${kind}`); 38 | 39 | expect(() => GraphQLPrimary.parseLiteral({ 40 | kind: Kind.STRING, 41 | value: faker.lorem.word(), 42 | loc: { start: 0, end: 10 }, 43 | })).toThrowError('Not a valid PrimaryType'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/type/__tests__/GraphQLJsonField.test.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import faker from 'faker'; 3 | import { GraphQLID } from 'graphql'; 4 | import GraphQLJsonField from '../GraphQLJsonField'; 5 | 6 | describe('GraphQLJsonField Type', () => { 7 | it('constructor', async () => { 8 | const field1 = new GraphQLJsonField(GraphQLID); 9 | expect(field1.type).toBe(GraphQLID); 10 | expect(field1.field).toBe('detail'); 11 | const fieldName = faker.lorem.word(); 12 | const field2 = new GraphQLJsonField({ type: GraphQLID, field: fieldName }); 13 | expect(field2.type).toBe(GraphQLID); 14 | expect(field2.field).toBe(fieldName); 15 | 16 | expect(() => new GraphQLJsonField()).toThrowError( 17 | 'Can only create json field of a GraphQLType but got: undefined.' 18 | ); 19 | }); 20 | 21 | it('resolve', async () => { 22 | const snake = `${faker.lorem.word()}_${faker.lorem.word()}`; 23 | const camel = _.camelCase(snake); 24 | const object = faker.helpers.userCard(); 25 | const value = JSON.stringify(object); 26 | const key = _.sample(_.keys(object)); 27 | 28 | const field1 = new GraphQLJsonField({ 29 | type: GraphQLID, 30 | field: camel, 31 | }); 32 | expect(field1.resolve( 33 | _.set({}, camel, object), {}, {}, { fieldName: key } 34 | )).toEqual(object[key]); 35 | expect(field1.resolve( 36 | _.set({}, snake, value), {}, {}, { fieldName: key } 37 | )).toEqual(object[key]); 38 | 39 | const field2 = new GraphQLJsonField(GraphQLID); 40 | expect(field2.resolve(_.set({}, camel, object), {}, {}, { fieldName: key })).toBeNull(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/command/follow.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Project from '../model/Project'; 3 | import Event from '../model/Event'; 4 | import Interaction from '../model/Interaction'; 5 | 6 | const typeNmae = { 7 | Event: '活動', 8 | Project: '專案', 9 | }; 10 | 11 | async function appendFollower(user, inputs) { 12 | const { target } = inputs; 13 | if (!target) return 'Sorry! 找不到專案或活動'; 14 | 15 | await target.appendValue('followerIds', user); 16 | 17 | const { name } = target.constructor; 18 | return `Done, 追蹤 ${target.title} ${typeNmae[name]}`; 19 | } 20 | 21 | async function inputTarget(user) { 22 | const targets = _.concat( 23 | await (new Event()).whereUnfollowed(user).whereBefore().fetchAll(), 24 | await (new Project()).whereUnfollowed(user).fetchAll(), 25 | ); 26 | 27 | if (targets.length < 1) return 'Sorry! 找不到未追蹤的專案或活動'; 28 | 29 | Interaction.set(user, async ({ text }) => { 30 | const index = _.trim(text); 31 | const target = targets[index - 1]; 32 | return appendFollower(user, { target }); 33 | }); 34 | 35 | return [ 36 | '請選擇要追蹤的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`', 37 | _.map(targets, (target, idx) => ( 38 | `*${idx + 1}*. ${target.title} ${typeNmae[target.constructor.name]}`) 39 | ).join('\n'), 40 | ].join('\n'); 41 | } 42 | 43 | export default async function ({ value }, { user }) { 44 | const title = _.trim(value); 45 | if (title) { 46 | const target = 47 | await (new Event({ title })).whereBefore().fetch() || 48 | await (new Project({ title })).fetch(); 49 | 50 | return appendFollower(user, { target }); 51 | } 52 | 53 | return inputTarget(user); 54 | } 55 | -------------------------------------------------------------------------------- /src/command/unfollow.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Project from '../model/Project'; 3 | import Event from '../model/Event'; 4 | import Interaction from '../model/Interaction'; 5 | 6 | const typeNmae = { 7 | Event: '活動', 8 | Project: '專案', 9 | }; 10 | 11 | async function removeFollower(user, inputs) { 12 | const { target } = inputs; 13 | if (!target) return 'Sorry! 找不到專案或活動'; 14 | 15 | await target.removeValue('followerIds', user); 16 | 17 | const { name } = target.constructor; 18 | return `Done, 取消追蹤 ${target.title} ${typeNmae[name]}`; 19 | } 20 | 21 | async function inputTarget(user) { 22 | const targets = _.concat( 23 | await (new Event()).whereFollowed(user).whereBefore().fetchAll(), 24 | await (new Project()).whereFollowed(user).fetchAll(), 25 | ); 26 | 27 | if (targets.length < 1) return 'Sorry! 找不到已追蹤的專案或活動'; 28 | 29 | Interaction.set(user, async ({ text }) => { 30 | const index = _.trim(text); 31 | const target = targets[index - 1]; 32 | return removeFollower(user, { target }); 33 | }); 34 | 35 | return [ 36 | '請選擇要取消追蹤的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`', 37 | _.map(targets, (target, idx) => ( 38 | `*${idx + 1}*. ${target.title} ${typeNmae[target.constructor.name]}` 39 | )).join('\n'), 40 | ].join('\n'); 41 | } 42 | 43 | export default async function ({ value }, { user }) { 44 | const title = _.trim(value); 45 | if (title) { 46 | const target = 47 | await (new Event({ title })).whereBefore().fetch() || 48 | await (new Project({ title })).fetch(); 49 | 50 | return removeFollower(user, { target }); 51 | } 52 | 53 | return inputTarget(user); 54 | } 55 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/projects.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`projects command database projects 1`] = ` 4 | Array [ 5 | Array [ 6 | Object { 7 | "attachments": Array [ 8 | Object { 9 | "color": "#000", 10 | "mrkdwn_in": Array [ 11 | "text", 12 | "pretext", 13 | "fields", 14 | ], 15 | "text": "坑主: <@U03B2AB13> 16 | 找人 找坑", 17 | "thumb_url": "https://project.g0v.tw/index.png", 18 | "title": "g0ver-box", 19 | "title_link": "https://project.g0v.tw/", 20 | }, 21 | ], 22 | "channel": "D100", 23 | "text": "下列專案歡迎你的加入:", 24 | }, 25 | ], 26 | ] 27 | `; 28 | 29 | exports[`projects command successful query 1`] = ` 30 | Array [ 31 | "select * from project where deleted_at is null limit 1000", 32 | ] 33 | `; 34 | 35 | exports[`projects command successful query 2`] = ` 36 | Array [ 37 | Array [ 38 | Object { 39 | "attachments": Array [ 40 | Object { 41 | "color": "#000", 42 | "mrkdwn_in": Array [ 43 | "text", 44 | "pretext", 45 | "fields", 46 | ], 47 | "text": "坑主: <@U03B2AB13> 48 | 媒合 挖坑 推坑", 49 | "thumb_url": "https://g0v.tw/thumb.gif", 50 | "title": "g0ver box", 51 | "title_link": "https://g0v.tw/", 52 | }, 53 | Object { 54 | "color": "#000", 55 | "mrkdwn_in": Array [ 56 | "text", 57 | "pretext", 58 | "fields", 59 | ], 60 | "text": "坑主: <@U03B2AB00>", 61 | "thumb_url": null, 62 | "title": "g0v today", 63 | "title_link": "https://g0v.tw/", 64 | }, 65 | ], 66 | "channel": "D100", 67 | "text": "下列專案歡迎你的加入:", 68 | }, 69 | ], 70 | ] 71 | `; 72 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/events.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`projects command database events 1`] = ` 4 | Array [ 5 | Array [ 6 | Object { 7 | "attachments": Array [ 8 | Object { 9 | "color": "#000", 10 | "mrkdwn_in": Array [ 11 | "text", 12 | "pretext", 13 | "fields", 14 | ], 15 | "text": "<!date^1514732400000^{date_num} at {time}|unknown> 16 | 聯絡人: <@U03B2AB13>", 17 | "title": "基礎建設松", 18 | "title_link": "https://events.g0v.tw/", 19 | }, 20 | ], 21 | "channel": "D100", 22 | "text": "下列活動歡迎你的參加:", 23 | }, 24 | ], 25 | ] 26 | `; 27 | 28 | exports[`projects command successful query 1`] = ` 29 | Array [ 30 | "select * from event where datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 31 | ] 32 | `; 33 | 34 | exports[`projects command successful query 2`] = ` 35 | Array [ 36 | Array [ 37 | Object { 38 | "attachments": Array [ 39 | Object { 40 | "color": "#000", 41 | "mrkdwn_in": Array [ 42 | "text", 43 | "pretext", 44 | "fields", 45 | ], 46 | "text": "<!date^1509861600000^{date_num} at {time}|unknown> 47 | 聯絡人: <@U03B2AB13>", 48 | "title": "基礎建設松", 49 | "title_link": "https://g0v.tw/", 50 | }, 51 | Object { 52 | "color": "#000", 53 | "mrkdwn_in": Array [ 54 | "text", 55 | "pretext", 56 | "fields", 57 | ], 58 | "text": "<!date^1512916200000^{date_num} at {time}|unknown> 59 | 聯絡人: <@U03B2AB00>", 60 | "title": "新聞新聞松", 61 | "title_link": null, 62 | }, 63 | ], 64 | "channel": "D100", 65 | "text": "下列活動歡迎你的參加:", 66 | }, 67 | ], 68 | ] 69 | `; 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # g0ver-box 2 | 3 | A macthmaking slack-bot for g0v projects and g0v attendees. 4 | Tell bot the skills you have or issues you like. Bot would match attendees and projects. 5 | 6 | ## How to use 7 | 8 | - help 使用說明 9 | - in <project name / task> <datetime> 表示正在某個坑裡,datetime 為預計何時出坑,預設 24 hr 後出坑 10 | - out 表示已經離開某個坑 11 | - all 查詢有哪些 g0ver 正在哪些坑 12 | - add <skill name> 新增技能 13 | - del <skill name> 刪除技能 14 | - whoami 查詢自己有哪些技能 15 | - search <skill name> 搜尋哪些 g0ver 會此技能 16 | - whois <slack id> 查詢此 g0ver 有哪些技能 17 | 18 | ## Environment Variables 19 | 20 | * DATABASE_URL (required) 21 | * default value is `postgresql://localhost:54320/g0vhub` 22 | * SLACK_TOKEN (required) 23 | * SLACK_BOT_ID 24 | * default value is `g0ver` 25 | * PORT 26 | * default value is `8080` 27 | 28 | ## Dependencies 29 | 30 | * nodemon 31 | * babel-node 32 | * knex 33 | * eslint 34 | * jest 35 | 36 | ## Yarn supported 37 | 38 | We suggest you to use yarn to manage package. 39 | 40 | ## Setup developing environment 41 | 42 | We recommended you using postgres docker as your database. 43 | 44 | ``` 45 | docker run -p 54320:5432 --name g0v-postgres -e POSTGRES_PASSWORD='' -e POSTGRES_DB='g0vhub' -d postgres 46 | ``` 47 | 48 | ### Configure database hostname 49 | 50 | If you were using docker-machine/virtual machine please edit `host` in `knexfile.js`. 51 | 52 | ``` 53 | host: <DOCKER_MACHINE_HOST>, 54 | ``` 55 | 56 | ## Migrate DB Schema 57 | 58 | Please run this command when database started up. 59 | 60 | ``` 61 | yarn migrate 62 | ``` 63 | or 64 | ``` 65 | npm run migrate 66 | ``` 67 | 68 | ## Start your own g0ver 69 | 70 | ``` 71 | SLACK_TOKEN='xoxb-123456789012-xxxxxxXXXxxxXXXXXXXXXxxx' yarn start 72 | ``` 73 | or 74 | ``` 75 | SLACK_TOKEN='xoxb-123456789012-xxxxxxXXXxxxXXXXXXXXXxxx' npm start 76 | ``` 77 | 78 | ## Test 79 | 80 | Run all unit-tests 81 | 82 | ``` 83 | yarn test 84 | ``` 85 | or 86 | ``` 87 | npm test 88 | ``` 89 | 90 | Run the test related to files 91 | 92 | ``` 93 | yarn test-watch 94 | ``` 95 | or 96 | ``` 97 | npm test-watch 98 | ``` 99 | -------------------------------------------------------------------------------- /src/command/in.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import moment from 'moment'; 3 | import Interaction from '../model/Interaction'; 4 | import Task from '../model/Task'; 5 | import Project from '../model/Project'; 6 | 7 | async function save(user, inputs) { 8 | const query = Task.queryBuilder; 9 | await query 10 | .where({ user_id: user }) 11 | .whereNull('deleted_at') 12 | .where('expired_at', '>', new Date()) 13 | .update({ expired_at: new Date() }); 14 | 15 | const { hours, ...otherInputs } = inputs; 16 | 17 | const task = new Task({ 18 | ...otherInputs, 19 | expiredAt: moment().add(hours, 'hours').toDate(), 20 | }); 21 | await task.save(); 22 | 23 | return `Done, in${inputs.note ? ` ${inputs.note}` : ''} ${hours} hr`; 24 | } 25 | 26 | async function inputNote(user, inputs) { 27 | if (inputs.note) return save(user, inputs); 28 | 29 | Interaction.set(user, async ({ text }) => { 30 | let note = _.trim(text); 31 | if (note === 'skip') note = null; 32 | 33 | return save(user, { ...inputs, note }); 34 | }); 35 | 36 | return '是否為此次目標做個日誌?\n`(輸入 skip 可跳過)`'; 37 | } 38 | 39 | async function inputProject(user, inputs) { 40 | const query = new Project(); 41 | const projects = await query.fetchAll(); 42 | 43 | if (projects.length < 1) { 44 | return inputNote(user, inputs); 45 | } 46 | 47 | const handler = ({ text }) => { 48 | const index = _.trim(text); 49 | const project = projects[index - 1] || null; 50 | 51 | if (['skip', '0'].indexOf(index) < 0 && !project) { 52 | Interaction.set(user, handler); 53 | return 'Sorry! 找不到專案,請重試'; 54 | } 55 | 56 | return inputNote(user, { ...inputs, project }); 57 | }; 58 | 59 | Interaction.set(user, handler); 60 | 61 | return [ 62 | '請問是否為下列專案(輸入代碼)?\n`(輸入 skip 可跳過)`', 63 | '*0*. none', 64 | _.map(projects, (project, idx) => `*${idx + 1}*. ${project.title}`).join('\n'), 65 | ].join('\n'); 66 | } 67 | 68 | export default async function ({ value }, { user }) { 69 | const note = _.trim(value); 70 | 71 | const hours = parseInt(/ \d+$/i.exec(note), 10) || 8; 72 | 73 | return inputProject(user, { note: note.replace(/ \d+$/i, ''), hours }); 74 | } 75 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | type G0ver { 2 | id: Primary 3 | username: String 4 | skill: [String] 5 | project(id: Primary, title: ID): [Project] 6 | } 7 | 8 | # A connection to a list of items. 9 | type G0verConnection { 10 | # Information to aid in pagination. 11 | pageInfo: PageInfo! 12 | 13 | # A list of edges. 14 | edges: [G0verEdge] 15 | } 16 | 17 | # An edge in a connection. 18 | type G0verEdge { 19 | # The item at the end of the edge 20 | node: G0ver 21 | 22 | # A cursor for use in pagination 23 | cursor: String! 24 | } 25 | 26 | type Mutation { 27 | updateG0ver(input: UpdateG0verInput!): UpdateG0verPayload 28 | } 29 | 30 | # Information about pagination in a connection. 31 | type PageInfo { 32 | # When paginating forwards, are there more items? 33 | hasNextPage: Boolean! 34 | 35 | # When paginating backwards, are there more items? 36 | hasPreviousPage: Boolean! 37 | 38 | # When paginating backwards, the cursor to continue. 39 | startCursor: String 40 | 41 | # When paginating forwards, the cursor to continue. 42 | endCursor: String 43 | } 44 | 45 | scalar Primary 46 | 47 | type Project { 48 | id: Primary 49 | title: String 50 | description: String 51 | website: String 52 | github: String 53 | hackfoldr: String 54 | video: String 55 | g0ver(id: Primary, username: ID): [G0ver] 56 | } 57 | 58 | # A connection to a list of items. 59 | type ProjectConnection { 60 | # Information to aid in pagination. 61 | pageInfo: PageInfo! 62 | 63 | # A list of edges. 64 | edges: [ProjectEdge] 65 | } 66 | 67 | # An edge in a connection. 68 | type ProjectEdge { 69 | # The item at the end of the edge 70 | node: Project 71 | 72 | # A cursor for use in pagination 73 | cursor: String! 74 | } 75 | 76 | type Query { 77 | g0ver(id: Primary, username: ID, after: String, first: Int, before: String, last: Int): G0verConnection 78 | project(id: Primary, title: ID, after: String, first: Int, before: String, last: Int): ProjectConnection 79 | } 80 | 81 | input UpdateG0verInput { 82 | username: String! 83 | skill: [String] 84 | clientMutationId: String 85 | } 86 | 87 | type UpdateG0verPayload { 88 | g0ver: G0ver 89 | clientMutationId: String 90 | } 91 | -------------------------------------------------------------------------------- /src/command/create.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Project from '../model/Project'; 3 | import Interaction from '../model/Interaction'; 4 | 5 | async function saveProject(user, inputs) { 6 | const project = new Project({ ...inputs, user }); 7 | 8 | try { 9 | await project.save(); 10 | return `Done, create ${inputs.title}`; 11 | } catch (e) { 12 | return `Failed, ${e.message}`; 13 | } 14 | } 15 | 16 | function confirm(user, inputs) { 17 | Interaction.set(user, ({ text }) => { 18 | const check = _.trim(text); 19 | if (!(check === 'yes')) return '無法判斷的指令'; 20 | 21 | return saveProject(user, inputs); 22 | }); 23 | 24 | return { 25 | text: '請確認資料如下?(輸入 yes 完成建立)\n`(輸入 exit 可離開)`', 26 | attachments: [{ 27 | color: '#000', 28 | mrkdwn_in: ['text', 'pretext', 'fields'], 29 | thumb_url: inputs.thumb, 30 | title: inputs.title, 31 | title_link: inputs.url, 32 | text: _.filter([`坑主: <@${user}>`, inputs.tags]).join('\n'), 33 | }], 34 | }; 35 | } 36 | 37 | function inputThumb(user, inputs) { 38 | Interaction.set(user, async ({ text }) => { 39 | let thumb = _.trim(text); 40 | 41 | if (thumb === 'skip') thumb = null; 42 | 43 | return confirm(user, { ...inputs, thumb }); 44 | }); 45 | 46 | return '請輸入專案的縮圖網址?支援 GIF, JPEG, PNG 格式的 75px X 75px\n`(輸入 skip 可跳過)`'; 47 | } 48 | 49 | function inputTags(user, inputs) { 50 | Interaction.set(user, async ({ text }) => { 51 | let tags = _.trim(text); 52 | 53 | if (tags === 'skip') tags = null; 54 | 55 | return inputThumb(user, { ...inputs, tags }); 56 | }); 57 | 58 | return '請輸入專案的 關鍵字 ,多個關鍵字請用空白分隔 ?\n`(輸入 skip 可跳過)`'; 59 | } 60 | 61 | function inputURL(user, inputs) { 62 | Interaction.set(user, ({ text }) => { 63 | let url = _.trim(text, '<> '); 64 | 65 | if (url === 'skip') url = null; 66 | 67 | return inputTags(user, { ...inputs, url }); 68 | }); 69 | 70 | return '請輸入 共筆 / 討論 / 專業 連結?\n`(輸入 skip 可跳過)`'; 71 | } 72 | 73 | export default async function ({ title }, { user }) { 74 | const project = new Project({ title }); 75 | 76 | if (await project.fetch()) return 'Sorry! 發現相同的專案名稱'; 77 | 78 | const inputs = { title }; 79 | return inputURL(user, inputs); 80 | } 81 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/in.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`in command in <note> <hours> 1`] = ` 4 | Array [ 5 | "select * from project where deleted_at is null limit 1000", 6 | "update task set expired_at = DATE where user_id = U03B2AB13 and deleted_at is null and expired_at > DATE", 7 | "insert into task (created_at, expired_at, note, updated_at) values (DATE, DATE, fix issue, DATE) returning id", 8 | ] 9 | `; 10 | 11 | exports[`in command in <note> [hours] 1`] = ` 12 | Array [ 13 | "select * from project where deleted_at is null limit 1000", 14 | "update task set expired_at = DATE where user_id = U03B2AB13 and deleted_at is null and expired_at > DATE", 15 | "insert into task (created_at, expired_at, note, updated_at) values (DATE, DATE, fix issue, DATE) returning id", 16 | ] 17 | `; 18 | 19 | exports[`in command in [note] [hours] input note when has note 1`] = ` 20 | Array [ 21 | "select * from project where deleted_at is null limit 1000", 22 | "update task set expired_at = DATE where user_id = U03B2AB13 and deleted_at is null and expired_at > DATE", 23 | "insert into task (created_at, expired_at, note, project_id, updated_at) values (DATE, DATE, note information, 3, DATE) returning id", 24 | ] 25 | `; 26 | 27 | exports[`in command in [note] [hours] input note when hasn't note 1`] = ` 28 | Array [ 29 | "select * from project where deleted_at is null limit 1000", 30 | "update task set expired_at = DATE where user_id = U03B2AB13 and deleted_at is null and expired_at > DATE", 31 | "insert into task (created_at, expired_at, note, project_id, updated_at) values (DATE, DATE, null, 3, DATE) returning id", 32 | ] 33 | `; 34 | 35 | exports[`in command in [note] [hours] when found projects 1`] = ` 36 | Array [ 37 | "select * from project where deleted_at is null limit 1000", 38 | ] 39 | `; 40 | 41 | exports[`in command in [note] [hours] when no found projects 1`] = ` 42 | Array [ 43 | "select * from project where deleted_at is null limit 1000", 44 | "update task set expired_at = DATE where user_id = U03B2AB13 and deleted_at is null and expired_at > DATE", 45 | "insert into task (created_at, expired_at, note, updated_at) values (DATE, DATE, ya! a note, DATE) returning id", 46 | ] 47 | `; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "g0ver-box", 3 | "version": "1.0.0", 4 | "description": "A macthmaking slack-bot for g0v projects and g0v attendees.", 5 | "main": "./dist/app.js", 6 | "scripts": { 7 | "start": "node ./dist/app.js", 8 | "watch": "export NODE_ENV=development; nodemon --exec npm run start", 9 | "schema": "babel-node ./script/migration-schema.js", 10 | "build": "rm -rf dist; babel src -d dist --ignore '/__tests__/,/__mocks__/'", 11 | "heroku-postbuild": "npm run migrate; npm run build;", 12 | "test": "jest --coverage", 13 | "eslint": "eslint ./src", 14 | "migrate": "knex migrate:latest" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/g0v/g0ver-box.git" 19 | }, 20 | "author": "g0v", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/g0v/g0ver-box/issues" 24 | }, 25 | "homepage": "https://github.com/g0v/g0ver-box#readme", 26 | "jest": { 27 | "testEnvironment": "node", 28 | "clearMocks": true, 29 | "setupFiles": [ 30 | "<rootDir>/__mocks__/index.js" 31 | ], 32 | "snapshotSerializers": [ 33 | "<rootDir>/node_modules/jest-mock-knex/serializer" 34 | ], 35 | "coveragePathIgnorePatterns": [ 36 | "/node_modules/", 37 | "/migrations/" 38 | ] 39 | }, 40 | "dependencies": { 41 | "babel-cli": "^6.26.0", 42 | "babel-core": "^6.26.0", 43 | "babel-eslint": "^8.0.1", 44 | "babel-jest": "^21.2.0", 45 | "babel-polyfill": "^6.26.0", 46 | "babel-preset-react-native": "^1.9.0", 47 | "cz-conventional-changelog": "^1.2.0", 48 | "dataloader": "^1.3.0", 49 | "eslint": "^3.7.1", 50 | "eslint-config-airbnb": "^12.0.0", 51 | "eslint-plugin-import": "^1.16.0", 52 | "eslint-plugin-jsx-a11y": "^2.2.2", 53 | "eslint-plugin-react": "^6.3.0", 54 | "express": "^4.14.0", 55 | "express-graphql": "^0.5.4", 56 | "faker": "^3.1.0", 57 | "graphql": "^0.7.1", 58 | "graphql-relay": "^0.4.3", 59 | "graphql-tower": "^3.5.0", 60 | "jest": "^21.2.1", 61 | "jest-mock-knex": "^1.7.5", 62 | "jsonwebtoken": "^7.1.9", 63 | "knex": "^0.13.0", 64 | "lodash": "^4.16.4", 65 | "moment": "^2.19.1", 66 | "nodemon": "^1.11.0", 67 | "pg": "^7.3.0", 68 | "query-string": "^4.2.3", 69 | "slack": "8.4.2", 70 | "xregexp": "^3.2.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/command/jothon.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import moment from 'moment'; 3 | import Event from '../model/Event'; 4 | import Interaction from '../model/Interaction'; 5 | 6 | async function saveEvent(user, inputs) { 7 | const event = new Event({ ...inputs, user }); 8 | 9 | try { 10 | await event.save(); 11 | return `Done, jothon ${inputs.title}`; 12 | } catch (e) { 13 | return `Failed, ${e.message}`; 14 | } 15 | } 16 | 17 | function confirm(user, inputs) { 18 | Interaction.set(user, ({ text }) => { 19 | const check = _.trim(text); 20 | if (!(check === 'yes')) return '無法判斷的指令'; 21 | 22 | return saveEvent(user, inputs); 23 | }); 24 | 25 | return { 26 | text: '請確認資料如下?(輸入 yes 完成建立)\n`(輸入 exit 可離開)`', 27 | attachments: [{ 28 | color: '#000', 29 | mrkdwn_in: ['text', 'pretext', 'fields'], 30 | title: inputs.title, 31 | title_link: inputs.url, 32 | text: [ 33 | `<!date^${inputs.datetime.getTime()}^{date_num} at {time}|unknown>`, 34 | `聯絡人: <@${user}>`, 35 | ].join('\n'), 36 | }], 37 | }; 38 | } 39 | 40 | function inputURL(user, inputs) { 41 | Interaction.set(user, ({ text }) => { 42 | let url = _.trim(text, '<> '); 43 | 44 | if (url === 'skip') url = null; 45 | 46 | return confirm(user, { ...inputs, url }); 47 | }); 48 | 49 | return '請輸入 活動資訊 / 報名 連結?\n`(輸入 skip 可跳過)` `(輸入 exit 可離開)`'; 50 | } 51 | 52 | function inputDatetime(user, inputs) { 53 | Interaction.set(user, ({ text }) => { 54 | let datetime = _.trim(text); 55 | 56 | const date = moment(); 57 | 58 | if (!/^\d{4}-/.test(datetime)) datetime = `${date.year()}-${datetime}`; 59 | if (!/[-+][\d:]+$/.test(datetime)) datetime = `${datetime}+08`; 60 | 61 | datetime = moment(datetime); 62 | 63 | if (!datetime.isValid()) return '日期時間格式錯誤,請重新輸入'; 64 | 65 | if (datetime.isBefore()) { 66 | datetime.year(date.add(1, 'year').year()); 67 | } 68 | 69 | return inputURL(user, { ...inputs, datetime: datetime.toDate() }); 70 | }); 71 | 72 | return '請輸入 RFC2822 或 ISO8601 格式的 活動時間 預設為 +08 時區 (ex. 12-31 23:00+08)?\n`(輸入 exit 可離開)`'; 73 | } 74 | 75 | export default async function ({ title }, { user }) { 76 | const event = new Event({ title }); 77 | 78 | if (await event.fetch()) return 'Sorry! 發現相同的活動名稱'; 79 | 80 | const inputs = { title }; 81 | return inputDatetime(user, inputs); 82 | } 83 | -------------------------------------------------------------------------------- /src/command/__tests__/events.test.js: -------------------------------------------------------------------------------- 1 | /* eslint import/imports-first: 0 */ 2 | 3 | jest.mock('../../../knexfile', () => ({ 4 | client: 'pg', 5 | connection: { 6 | host: '127.0.0.1', 7 | user: 'postgres', 8 | password: null, 9 | database: 'g0ver_projects', 10 | }, 11 | })); 12 | 13 | import { client } from 'knex'; 14 | import Slack from '../../Slack'; 15 | import db from '../../model/Database'; 16 | import index from '../'; 17 | 18 | describe('projects command', () => { 19 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'events' }; 20 | 21 | it('successful query', async () => { 22 | client.mockReturnValueOnce([{ 23 | id: 10, 24 | title: '基礎建設松', 25 | userId: 'U03B2AB13', 26 | datetime: '2017-11-05T06:00:00', 27 | archive: { 28 | url: 'https://g0v.tw/', 29 | }, 30 | }, { 31 | id: 15, 32 | title: '新聞新聞松', 33 | userId: 'U03B2AB00', 34 | datetime: '2017-12-10T14:30:00', 35 | }]); 36 | 37 | await index(data); 38 | expect(client).toMatchSnapshot(); 39 | expect(client).toHaveBeenCalledTimes(1); 40 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 41 | }); 42 | 43 | describe('database', () => { 44 | beforeAll(async () => { 45 | await db('pg_tables').select('tablename').where('schemaname', 'public').map(({ tablename }) => ( 46 | db.schema.dropTable(tablename) 47 | )); 48 | await db.migrate.latest(); 49 | }); 50 | 51 | afterAll(async () => { 52 | await db.destroy(); 53 | }); 54 | 55 | it('jothon event', async () => { 56 | await index({ ...data, text: 'jothon 基礎建設松' }); 57 | await index({ ...data, text: '12-31 23:00' }); 58 | await index({ ...data, text: 'https://events.g0v.tw/' }); 59 | await index({ ...data, text: 'yes' }); 60 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 61 | channel: 'D100', 62 | text: 'Done, jothon 基礎建設松', 63 | }); 64 | }); 65 | 66 | it('events', async () => { 67 | await index(data); 68 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 69 | }); 70 | 71 | it('cancel event', async () => { 72 | await index({ ...data, text: 'cancel 基礎建設松' }); 73 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 74 | channel: 'D100', 75 | text: 'Done, 取消 基礎建設松 活動', 76 | }); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/command/notice.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Slack from '../Slack'; 3 | import Project from '../model/Project'; 4 | import Event from '../model/Event'; 5 | import Interaction from '../model/Interaction'; 6 | 7 | const typeNmae = { 8 | Event: '活動', 9 | Project: '專案', 10 | }; 11 | 12 | async function notifyFollower(user, inputs) { 13 | const { target, message } = inputs; 14 | 15 | await Slack.postMultiMessage(target.follower, message); 16 | 17 | const { name } = target.constructor; 18 | return `Done, 發送 ${target.title} ${typeNmae[name]}通知`; 19 | } 20 | 21 | function confirm(user, inputs) { 22 | Interaction.set(user, ({ text }) => { 23 | const check = _.trim(text); 24 | if (!(check === 'yes')) return '無法判斷的指令'; 25 | 26 | return notifyFollower(user, inputs); 27 | }); 28 | 29 | const { target } = inputs; 30 | const { name } = target.constructor; 31 | 32 | return { 33 | text: [ 34 | '請確認發送訊息通知的內容如下?(輸入 yes 立即發送)', 35 | '`(輸入 exit 可離開)`', 36 | '---', 37 | `來自 *${target.title}* ${typeNmae[name]}的通知:`, 38 | inputs.message, 39 | ].join('\n'), 40 | }; 41 | } 42 | 43 | async function importMessage(user, inputs) { 44 | if (inputs.message) return confirm(user, inputs); 45 | 46 | Interaction.set(user, async ({ text }) => { 47 | const message = _.trim(text); 48 | return confirm(user, { ...inputs, message }); 49 | }); 50 | 51 | return '請輸入發送訊息通知的內容?\n`(輸入 exit 可離開)`'; 52 | } 53 | 54 | async function inputTarget(user, inputs) { 55 | const targets = _.concat( 56 | await (new Event({ user })).whereBefore().fetchAll(), 57 | await (new Project({ user })).fetchAll(), 58 | ); 59 | 60 | if (targets.length < 1) return 'Sorry! 找不到可發訊息通知的專案或活動'; 61 | 62 | const handler = async ({ text }) => { 63 | const index = _.trim(text); 64 | const target = targets[index - 1]; 65 | 66 | if (!target) { 67 | Interaction.set(user, handler); 68 | return '錯誤的專案或活動代碼,請重新輸入'; 69 | } 70 | 71 | return importMessage(user, { ...inputs, target }); 72 | }; 73 | 74 | Interaction.set(user, handler); 75 | 76 | return [ 77 | '請選擇要發訊息通知的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`', 78 | _.map(targets, (target, idx) => ( 79 | `*${idx + 1}*. ${target.title} ${typeNmae[target.constructor.name]}`) 80 | ).join('\n'), 81 | ].join('\n'); 82 | } 83 | 84 | export default async function ({ value }, { user }) { 85 | const message = _.trim(value); 86 | 87 | return inputTarget(user, { message }); 88 | } 89 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/unfollow.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`unfollow command unfollow <title> successfully unfollow when has event 1`] = ` 4 | Array [ 5 | "select * from event where datetime > NOW() and deleted_at is null and title = g0ver box order by datetime asc limit 1", 6 | "update event set follower_ids = array_remove(follower_ids, U03B2AB13) where deleted_at is null and id = 5", 7 | ] 8 | `; 9 | 10 | exports[`unfollow command unfollow <title> successfully unfollow when has project 1`] = ` 11 | Array [ 12 | "select * from event where datetime > NOW() and deleted_at is null and title = g0ver box order by datetime asc limit 1", 13 | "select * from project where deleted_at is null and title = g0ver box limit 1", 14 | "update project set follower_ids = array_remove(follower_ids, U03B2AB13) where deleted_at is null and id = 5", 15 | ] 16 | `; 17 | 18 | exports[`unfollow command unfollow <title> when project and event does not exist 1`] = ` 19 | Array [ 20 | "select * from event where datetime > NOW() and deleted_at is null and title = g0ver box order by datetime asc limit 1", 21 | "select * from project where deleted_at is null and title = g0ver box limit 1", 22 | ] 23 | `; 24 | 25 | exports[`unfollow command unfollow [title] when project and event does not exist 1`] = ` 26 | Array [ 27 | "select * from event where follower_ids @> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 28 | "select * from project where follower_ids @> [U03B2AB13] and deleted_at is null limit 1000", 29 | ] 30 | `; 31 | 32 | exports[`unfollow command unfollow [title] when project or event is existed successfully unfollow events 1`] = ` 33 | Array [ 34 | "select * from event where follower_ids @> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 35 | "select * from project where follower_ids @> [U03B2AB13] and deleted_at is null limit 1000", 36 | "update project set follower_ids = array_remove(follower_ids, U03B2AB13) where deleted_at is null and id = 5", 37 | ] 38 | `; 39 | 40 | exports[`unfollow command unfollow [title] when project or event is existed successfully unfollow project 1`] = ` 41 | Array [ 42 | "select * from event where follower_ids @> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 43 | "select * from project where follower_ids @> [U03B2AB13] and deleted_at is null limit 1000", 44 | "update event set follower_ids = array_remove(follower_ids, U03B2AB13) where deleted_at is null and id = 5", 45 | ] 46 | `; 47 | -------------------------------------------------------------------------------- /src/command/__tests__/projects.test.js: -------------------------------------------------------------------------------- 1 | /* eslint import/imports-first: 0 */ 2 | 3 | jest.mock('../../../knexfile', () => ({ 4 | client: 'pg', 5 | connection: { 6 | host: '127.0.0.1', 7 | user: 'postgres', 8 | password: null, 9 | database: 'g0ver_projects', 10 | }, 11 | })); 12 | 13 | import { client } from 'knex'; 14 | import Slack from '../../Slack'; 15 | import db from '../../model/Database'; 16 | import index from '../'; 17 | 18 | describe('projects command', () => { 19 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'projects' }; 20 | 21 | it('successful query', async () => { 22 | client.mockReturnValueOnce([{ 23 | id: 10, 24 | title: 'g0ver box', 25 | userId: 'U03B2AB13', 26 | archive: { 27 | tags: '媒合 挖坑 推坑', 28 | url: 'https://g0v.tw/', 29 | thumb: 'https://g0v.tw/thumb.gif', 30 | }, 31 | }, { 32 | id: 15, 33 | title: 'g0v today', 34 | userId: 'U03B2AB00', 35 | archive: { 36 | url: 'https://g0v.tw/', 37 | }, 38 | }]); 39 | 40 | await index(data); 41 | expect(client).toMatchSnapshot(); 42 | expect(client).toHaveBeenCalledTimes(1); 43 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 44 | }); 45 | 46 | describe('database', () => { 47 | beforeAll(async () => { 48 | await db('pg_tables').select('tablename').where('schemaname', 'public').map(({ tablename }) => ( 49 | db.schema.dropTable(tablename) 50 | )); 51 | await db.migrate.latest(); 52 | }); 53 | 54 | afterAll(async () => { 55 | await db.destroy(); 56 | }); 57 | 58 | it('create project', async () => { 59 | await index({ ...data, text: 'create g0ver-box' }); 60 | await index({ ...data, text: 'https://project.g0v.tw/' }); 61 | await index({ ...data, text: '找人 找坑' }); 62 | await index({ ...data, text: 'https://project.g0v.tw/index.png' }); 63 | await index({ ...data, text: 'yes' }); 64 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 65 | channel: 'D100', 66 | text: 'Done, create g0ver-box', 67 | }); 68 | }); 69 | 70 | it('projects', async () => { 71 | await index(data); 72 | expect(Slack.postMessage.mock.calls).toMatchSnapshot(); 73 | }); 74 | 75 | it('remove projects', async () => { 76 | await index({ ...data, text: 'remove g0ver-box' }); 77 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 78 | channel: 'D100', 79 | text: 'Done, 刪除 g0ver-box 專案', 80 | }); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/help/__tests__/sqlpaser.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import faker from 'faker'; 3 | import sqlparser from '../sqlparser'; 4 | import { connection } from '../../model/Database'; 5 | 6 | const data = _.mapKeys(_.uniq(faker.lorem.words(10).split(' ')), value => `${value}Key`); 7 | const otherData = _.mapKeys(_.uniq(faker.lorem.words(10).split(' ')), value => `${value}Key`); 8 | 9 | describe('sqlparser help', () => { 10 | it('mock of number', async () => { 11 | const table1 = faker.lorem.word(); 12 | const table2 = faker.lorem.word(); 13 | const sql1 = connection(table1).where(data).toSQL(); 14 | const sql2 = connection(table2).where(otherData).toSQL(); 15 | expect(sqlparser(sql1)).toEqual({ ...data, table: table1, method: 'select' }); 16 | const mock = jest.fn(); 17 | mock({}, sql1); mock({}, sql2); 18 | expect(sqlparser(mock)).toEqual({ ...otherData, table: table2, method: 'select' }); 19 | expect(sqlparser(mock, 0)).toEqual({ ...data, table: table1, method: 'select' }); 20 | }); 21 | 22 | it('when setting omit', async () => { 23 | const table1 = faker.lorem.word(); 24 | const table2 = faker.lorem.word(); 25 | const sql1 = connection(table1).where(data).toSQL(); 26 | const sql2 = connection(table2).where(otherData).toSQL(); 27 | const key1 = `${_.sample(data)}Key`; 28 | const key2 = `${_.sample(otherData)}Key`; 29 | const mock = jest.fn(); 30 | mock({}, sql1); mock({}, sql2); 31 | expect(sqlparser(mock, [key2])).toEqual({ ..._.set({ ...otherData }, key2, true), table: table2, method: 'select' }); 32 | expect(sqlparser(mock, 0, [key1])).toEqual({ ..._.set({ ...data }, key1, true), table: table1, method: 'select' }); 33 | }); 34 | 35 | it('select', async () => { 36 | const limit = _.random(1, 99); 37 | const offset = _.random(1, 99); 38 | const table = faker.lorem.word(); 39 | 40 | const sql = connection(table) 41 | .where(data).limit(limit).offset(offset) 42 | .toSQL(); 43 | expect(sqlparser(sql)).toEqual({ ...data, limit, offset, table, method: 'select' }); 44 | }); 45 | 46 | it('update', async () => { 47 | const id = _.random(1, 99); 48 | const table = faker.lorem.word(); 49 | const sql = connection(table).where({ id }).update(data).toSQL(); 50 | expect(sqlparser(sql)).toEqual({ ...data, id, table, method: 'update' }); 51 | }); 52 | 53 | it('insert', async () => { 54 | const table = faker.lorem.word(); 55 | const sql = connection(table).insert(data).toSQL(); 56 | expect(sqlparser(sql)).toEqual({ ...data, table, method: 'insert' }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/notice.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`notice command database follow 1`] = ` 4 | Array [ 5 | "select * from event where follower_ids <> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 6 | "select * from project where follower_ids <> [U03B2AB13] and deleted_at is null limit 1000", 7 | "update project set follower_ids = array_append(array_remove(follower_ids, U03B2AB13), U03B2AB13) where deleted_at is null and id = 1", 8 | ] 9 | `; 10 | 11 | exports[`notice command database notice 1`] = ` 12 | Array [ 13 | "select * from event where datetime > NOW() and deleted_at is null and user_id = U03B2AB13 order by datetime asc limit 1000", 14 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 15 | ] 16 | `; 17 | 18 | exports[`notice command database unfollow 1`] = ` 19 | Array [ 20 | "select * from event where follower_ids @> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 21 | "select * from project where follower_ids @> [U03B2AB13] and deleted_at is null limit 1000", 22 | "update project set follower_ids = array_remove(follower_ids, U03B2AB13) where deleted_at is null and id = 1", 23 | ] 24 | `; 25 | 26 | exports[`notice command notice <message> successfully notice when has event 1`] = ` 27 | Array [ 28 | "select * from event where datetime > NOW() and deleted_at is null and user_id = U03B2AB13 order by datetime asc limit 1000", 29 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 30 | ] 31 | `; 32 | 33 | exports[`notice command notice <message> successfully notice when has project 1`] = ` 34 | Array [ 35 | "select * from event where datetime > NOW() and deleted_at is null and user_id = U03B2AB13 order by datetime asc limit 1000", 36 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 37 | ] 38 | `; 39 | 40 | exports[`notice command notice <message> when project and event does not exist 1`] = ` 41 | Array [ 42 | "select * from event where datetime > NOW() and deleted_at is null and user_id = U03B2AB13 order by datetime asc limit 1000", 43 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 44 | ] 45 | `; 46 | 47 | exports[`notice command notie [message] 1`] = ` 48 | Array [ 49 | "select * from event where datetime > NOW() and deleted_at is null and user_id = U03B2AB13 order by datetime asc limit 1000", 50 | "select * from project where deleted_at is null and user_id = U03B2AB13 limit 1000", 51 | ] 52 | `; 53 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/follow.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`follow command follow <title> successfully follow when has event 1`] = ` 4 | Array [ 5 | "select * from event where datetime > NOW() and deleted_at is null and title = g0ver box order by datetime asc limit 1", 6 | "update event set follower_ids = array_append(array_remove(follower_ids, U03B2AB13), U03B2AB13) where deleted_at is null and id = 5", 7 | ] 8 | `; 9 | 10 | exports[`follow command follow <title> successfully follow when has project 1`] = ` 11 | Array [ 12 | "select * from event where datetime > NOW() and deleted_at is null and title = g0ver box order by datetime asc limit 1", 13 | "select * from project where deleted_at is null and title = g0ver box limit 1", 14 | "update project set follower_ids = array_append(array_remove(follower_ids, U03B2AB13), U03B2AB13) where deleted_at is null and id = 5", 15 | ] 16 | `; 17 | 18 | exports[`follow command follow <title> when project and event does not exist 1`] = ` 19 | Array [ 20 | "select * from event where datetime > NOW() and deleted_at is null and title = g0ver box order by datetime asc limit 1", 21 | "select * from project where deleted_at is null and title = g0ver box limit 1", 22 | ] 23 | `; 24 | 25 | exports[`follow command follow [title] when project and event does not exist 1`] = ` 26 | Array [ 27 | "select * from event where follower_ids <> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 28 | "select * from project where follower_ids <> [U03B2AB13] and deleted_at is null limit 1000", 29 | ] 30 | `; 31 | 32 | exports[`follow command follow [title] when project or event is existed successfully follow events 1`] = ` 33 | Array [ 34 | "select * from event where follower_ids <> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 35 | "select * from project where follower_ids <> [U03B2AB13] and deleted_at is null limit 1000", 36 | "update project set follower_ids = array_append(array_remove(follower_ids, U03B2AB13), U03B2AB13) where deleted_at is null and id = 5", 37 | ] 38 | `; 39 | 40 | exports[`follow command follow [title] when project or event is existed successfully follow project 1`] = ` 41 | Array [ 42 | "select * from event where follower_ids <> [U03B2AB13] and datetime > NOW() and deleted_at is null order by datetime asc limit 1000", 43 | "select * from project where follower_ids <> [U03B2AB13] and deleted_at is null limit 1000", 44 | "update event set follower_ids = array_append(array_remove(follower_ids, U03B2AB13), U03B2AB13) where deleted_at is null and id = 5", 45 | ] 46 | `; 47 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import _ from 'lodash'; 3 | import express from 'express'; 4 | import expressGraphQL from 'express-graphql'; 5 | import DataLoader from 'dataloader'; 6 | import Slack, { bot } from './Slack'; 7 | import Schema from './Schema'; 8 | import command from './command'; 9 | import G0ver from './model/G0ver'; 10 | 11 | const NODE_PORT = process.env.PORT || 8080; 12 | const BOT_NAME = process.env.SLACK_BOT_ID || 'g0ver'; 13 | 14 | const users = new DataLoader(keys => Promise.all(_.map(keys, async (user) => { 15 | const reply = await Slack.userInfo({ user }); 16 | return _.get(reply, 'user.profile.display_name', _.get(reply, 'user.name', null)); 17 | }))); 18 | 19 | const server = express(); 20 | 21 | server.use('/', expressGraphQL({ 22 | schema: Schema, 23 | pretty: true, 24 | graphiql: true, 25 | formatError: (error) => { 26 | console.error(error); 27 | return { 28 | name: error.name || 'UnknownError', 29 | message: error.message || 'Unknown Error', 30 | }; 31 | }, 32 | })); 33 | 34 | server.listen(NODE_PORT, () => console.log( 35 | `GraphQL Server is now running on http://localhost:${NODE_PORT}` 36 | )); 37 | 38 | bot.message(async (data) => { 39 | const { channel, user, username } = data; 40 | const text = data.text.replace(new RegExp(` *<@${BOT_NAME}> *`, 'i'), ''); 41 | 42 | if (username === 'bot' || user === BOT_NAME) return; 43 | 44 | if (!( 45 | /^D/.test(channel) || 46 | (/^C/.test(channel) && (data.text || '').search(`<@${BOT_NAME}>`) > -1) 47 | )) return; 48 | 49 | console.log('rtm: ', JSON.stringify(data)); 50 | 51 | const name = await users.load(user); 52 | 53 | command({ ...data, name, text }); 54 | }); 55 | 56 | 57 | let generalId; 58 | bot.member_joined_channel(async (data) => { 59 | const { channel, user } = data; 60 | 61 | if (!generalId) { 62 | const reply = await Slack.channelInfo({ channel }); 63 | const isGeneral = _.get(reply, 'channel.is_general', false); 64 | if (isGeneral) generalId = channel; 65 | } 66 | 67 | if (channel !== generalId) return; 68 | 69 | const reply = Slack.postMessage({ 70 | channel: user, 71 | text: [ 72 | `歡迎 <@${user}> 來到 g0v 社群`, 73 | '私訊 <@g0ver> 並輸入 `help`,我將熱血為您服務。也可以輸入 `search g0v大使` 這些人可以協助你了解 g0v', 74 | ].join('\n'), 75 | attachments: [{ 76 | color: '#000', 77 | mrkdwn_in: ['text', 'pretext', 'fields'], 78 | text: '*了解更多*\n <http://g0v.tw/zh-TW/manifesto.html|g0v 宣言> - <https://g0v-jothon.kktix.cc/|g0v 揪松團> - <https://www.facebook.com/g0v.tw/|g0v 粉絲專頁> - <https://github.com/g0v|g0v Github>', 79 | }], 80 | }); 81 | 82 | const g0ver = await G0ver.load(user) || await new G0ver({ id: user }).insert(); 83 | g0ver.channel = reply.channel; 84 | await g0ver.save(); 85 | }); 86 | -------------------------------------------------------------------------------- /src/command/help.js: -------------------------------------------------------------------------------- 1 | export default async function () { 2 | return { 3 | text: '歡迎來到 g0v。 社群是自主參與,成果開放\n私訊 <@g0ver> 使用下列指令,當需要 *技能* 時可以找到沒有人,也可以發現 *適合入的坑* ,或者輸入 `search g0v大使` 這些人都願意推你入坑。', 4 | attachments: [{ 5 | color: '#000', 6 | mrkdwn_in: ['text', 'pretext', 'fields'], 7 | text: [ 8 | '*關鍵字 技能 議題*', 9 | '`add`, `del`, `whoami`', 10 | '`add <hashtag>` 新增關鍵字,逗號可以多筆 ( ex. add A, B )', 11 | '`del <hashtag>` 刪除關鍵字,逗號可以多筆 ( ex. del A, B )', 12 | '`whoami` 查詢 自己 登錄了哪些關鍵字', 13 | ].join('\n'), 14 | }, { 15 | color: '#000', 16 | mrkdwn_in: ['text', 'pretext', 'fields'], 17 | text: [ 18 | '*挖坑 找坑*', 19 | '`create`, `remove`, `projects`', 20 | '`create <title>` 挖坑, title 為專案名稱', 21 | '`remove [title]` 清坑, title ( 選填 ) 為專案名稱', 22 | '`projects` 列出所有的坑', 23 | ].join('\n'), 24 | }, { 25 | color: '#000', 26 | mrkdwn_in: ['text', 'pretext', 'fields'], 27 | text: [ 28 | '*推坑 找人*', 29 | '`whois`, `search`', 30 | '`whois <@username>` 查詢 g0ver 登錄了哪些關鍵字 ( ex. whois @yutin )', 31 | '`search <hashtag>` *搜尋哪些 g0ver 有此關鍵字*', 32 | ].join('\n'), 33 | }, { 34 | color: '#000', 35 | mrkdwn_in: ['text', 'pretext', 'fields'], 36 | text: [ 37 | '*入坑 出坑*', 38 | '`in`, `all`, `out`', 39 | '`in [note] [hours]` 進入某個坑裡,hours ( 選填 ) 預計幾小時後出坑', 40 | '`out` 離開某個坑', 41 | '`all` 顯示 g0ver 正在哪些坑,也許他們需要你的加入', 42 | ].join('\n'), 43 | }, { 44 | color: '#000', 45 | mrkdwn_in: ['text', 'pretext', 'fields'], 46 | text: [ 47 | '*揪松 找松*', 48 | '`jothon`, `cancel`, `events`, `follow`, `unfollow`, `notice`', 49 | '`jothon <title>` 揪松, title 為活動名稱', 50 | '`cancel [title]` 取消活動, title ( 選填 ) 為活動名稱', 51 | '`events` 列出即將到來的大松小松', 52 | ].join('\n'), 53 | }, { 54 | color: '#000', 55 | mrkdwn_in: ['text', 'pretext', 'fields'], 56 | text: [ 57 | '*追蹤專案 追蹤活動*', 58 | '`follow`, `unfollow`', 59 | '`follow [title]` 追蹤, title ( 選填 ) 為專案或活動名稱', 60 | '`unfollow [title]` 取消追蹤, title ( 選填 ) 為專案或活動名稱', 61 | '`notice <message>` 發訊息通知關注者, message 為訊息內容', 62 | ].join('\n'), 63 | }, { 64 | color: '#000', 65 | mrkdwn_in: ['text', 'pretext', 'fields'], 66 | text: [ 67 | '*座右銘*', 68 | '`slogan`', 69 | '`slogan <text>` 持續關注的議題、來自哪裡 或 URL 讓大家認識你', 70 | ].join('\n'), 71 | }, { 72 | color: '#000', 73 | mrkdwn_in: ['text', 'pretext', 'fields'], 74 | text: '*了解更多*\n <http://g0v.tw/zh-TW/manifesto.html|g0v 宣言> - <https://g0v-jothon.kktix.cc/|g0v 揪松團> - <https://github.com/g0v|g0v Github>', 75 | }], 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/query/__tests__/G0verQuery.test.js: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import _ from 'lodash'; 3 | import faker from 'faker'; 4 | import sqlparser from '../../help/sqlparser'; 5 | import Schema from '../../Schema'; 6 | import { query } from '../../model/Database'; 7 | 8 | const data = _.range(1, 5).map(() => ({ 9 | id: _.random(1, 999), 10 | username: faker.internet.userName(), 11 | skill: _.uniq(faker.lorem.words(5).split(' ')), 12 | project_list: _.range(1, 5).map(() => ({ id: _.random(1, 999) })), 13 | })); 14 | 15 | const request = id => ` 16 | query { 17 | g0ver${id ? ` (id: ${id})` : ''} { 18 | edges { node { 19 | id 20 | username 21 | skill 22 | project { id } 23 | } } 24 | } 25 | } 26 | `; 27 | 28 | describe('G0ver Query', () => { 29 | it('find g0ver', async () => { 30 | const projectId = _.random(1, 999); 31 | query.mockClear(); 32 | query.mockReturnValueOnce(Promise.resolve(data)); 33 | const result = await graphql(Schema, request(), { id: projectId }, {}); 34 | expect(result.errors).toBeUndefined(); 35 | expect(sqlparser(query, 0)).toEqual({ project_id: projectId, table: 'g0ver_project', method: 'select' }); 36 | const edges = _.get(result, 'data.g0ver.edges'); 37 | _.forEach(edges, ({ node }, idx) => { 38 | expect(node).toEqual({ 39 | ..._.omit(data[idx], 'project_list'), 40 | project: [], 41 | }); 42 | }); 43 | expect(_.size(edges)).toBe(data.length); 44 | }); 45 | 46 | it('query a g0ver', async () => { 47 | const sample = _.sample(data); 48 | 49 | query.mockClear(); 50 | query.mockReturnValueOnce(Promise.resolve([sample])); 51 | query.mockReturnValueOnce(Promise.resolve(sample.project_list)); 52 | const result = await graphql(Schema, request(sample.id), {}, {}); 53 | expect(result.errors).toBeUndefined(); 54 | expect(sqlparser(query, 0)).toEqual({ id: sample.id, limit: 1, table: 'g0ver', method: 'select' }); 55 | expect(sqlparser(query, 1)).toEqual({ g0ver_id: sample.id, table: 'g0ver_project', method: 'select' }); 56 | const edges = _.get(result, 'data.g0ver.edges'); 57 | expect(edges[0].node).toEqual({ 58 | ..._.omit(sample, ['project_list']), 59 | project: sample.project_list, 60 | }); 61 | expect(_.size(edges)).toBe(1); 62 | }); 63 | 64 | it('query any g0ver', async () => { 65 | query.mockClear(); 66 | query.mockReturnValueOnce(Promise.resolve(data)); 67 | const result = await graphql(Schema, request()); 68 | expect(result.errors).toBeUndefined(); 69 | expect(sqlparser(query, 0)).toEqual({ table: 'g0ver', method: 'select' }); 70 | const edges = _.get(result, 'data.g0ver.edges'); 71 | _.forEach(edges, ({ node }, idx) => { 72 | expect(node).toEqual({ 73 | ..._.omit(data[idx], 'project_list'), 74 | project: [], 75 | }); 76 | }); 77 | expect(_.size(edges)).toBe(data.length); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/command/__tests__/create.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('create command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'create g0ver-box' }; 7 | 8 | it('when project does not exist', async () => { 9 | client.mockReturnValueOnce([]); 10 | client.mockReturnValueOnce(['5']); 11 | 12 | await index(data); 13 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 14 | channel: 'D100', 15 | text: '請輸入 共筆 / 討論 / 專業 連結?\n`(輸入 skip 可跳過)`', 16 | }); 17 | 18 | await index({ ...data, text: 'https://project.g0v.tw/' }); 19 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 20 | channel: 'D100', 21 | text: '請輸入專案的 關鍵字 ,多個關鍵字請用空白分隔 ?\n`(輸入 skip 可跳過)`', 22 | }); 23 | 24 | await index({ ...data, text: '找人 找坑' }); 25 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 26 | channel: 'D100', 27 | text: '請輸入專案的縮圖網址?支援 GIF, JPEG, PNG 格式的 75px X 75px\n`(輸入 skip 可跳過)`', 28 | }); 29 | 30 | await index({ ...data, text: 'https://project.g0v.tw/index.png' }); 31 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 32 | channel: 'D100', 33 | text: '請確認資料如下?(輸入 yes 完成建立)\n`(輸入 exit 可離開)`', 34 | })); 35 | 36 | await index({ ...data, text: 'yes' }); 37 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 38 | channel: 'D100', 39 | text: 'Done, create g0ver-box', 40 | }); 41 | 42 | expect(client).toMatchSnapshot(); 43 | }); 44 | 45 | it('when project is existed', async () => { 46 | client.mockReturnValueOnce([{ id: 5 }]); 47 | 48 | await index(data); 49 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 50 | channel: 'D100', 51 | text: 'Sorry! 發現相同的專案名稱', 52 | }); 53 | 54 | expect(client).toMatchSnapshot(); 55 | }); 56 | 57 | it('when use skip', async () => { 58 | client.mockReturnValueOnce([]); 59 | client.mockReturnValueOnce(['5']); 60 | 61 | await index(data); 62 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 63 | channel: 'D100', 64 | text: '請輸入 共筆 / 討論 / 專業 連結?\n`(輸入 skip 可跳過)`', 65 | }); 66 | 67 | await index({ ...data, text: 'skip' }); 68 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 69 | channel: 'D100', 70 | text: '請輸入專案的 關鍵字 ,多個關鍵字請用空白分隔 ?\n`(輸入 skip 可跳過)`', 71 | }); 72 | 73 | await index({ ...data, text: 'skip' }); 74 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 75 | channel: 'D100', 76 | text: '請輸入專案的縮圖網址?支援 GIF, JPEG, PNG 格式的 75px X 75px\n`(輸入 skip 可跳過)`', 77 | }); 78 | 79 | await index({ ...data, text: 'skip' }); 80 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 81 | channel: 'D100', 82 | text: '請確認資料如下?(輸入 yes 完成建立)\n`(輸入 exit 可離開)`', 83 | })); 84 | 85 | await index({ ...data, text: 'yes' }); 86 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 87 | channel: 'D100', 88 | text: 'Done, create g0ver-box', 89 | }); 90 | 91 | expect(client).toMatchSnapshot(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/mutation/__tests__/UpdateG0verMutation.test.js: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import _ from 'lodash'; 3 | import faker from 'faker'; 4 | import sqlparser from '../../help/sqlparser'; 5 | import Schema from '../../Schema'; 6 | import { query } from '../../model/Database'; 7 | 8 | const data = _.range(1, 5).map(() => ({ 9 | id: _.random(1, 999), 10 | username: faker.internet.userName(), 11 | skill: _.uniq(faker.lorem.words(5).split(' ')), 12 | })); 13 | 14 | const request = ({ username, skill }) => ` 15 | mutation { 16 | updateG0ver(input: { username: "${username}" ${skill ? `skill: ${JSON.stringify(skill)} ` : ''}}) { 17 | clientMutationId 18 | g0ver { username } 19 | } 20 | } 21 | `; 22 | 23 | describe('G0ver Mutation', () => { 24 | it('create a g0ver', async () => { 25 | const sample = _.sample(data); 26 | 27 | query.mockClear(); 28 | query.mockReturnValueOnce(Promise.resolve([])); 29 | const result = await graphql(Schema, request(sample)); 30 | expect(result.errors).toBeUndefined(); 31 | expect(sqlparser(query, 0)).toEqual({ username: sample.username, limit: 1, table: 'g0ver', method: 'select' }); 32 | expect(sqlparser(query, 1)).toEqual({ 33 | username: sample.username, 34 | skill: JSON.stringify(sample.skill), 35 | created_at: true, 36 | updated_at: true, 37 | table: 'g0ver', 38 | method: 'insert', 39 | }); 40 | const g0ver = _.get(result, 'data.updateG0ver'); 41 | expect(g0ver).toEqual({ 42 | clientMutationId: null, 43 | g0ver: { username: sample.username }, 44 | }); 45 | }); 46 | 47 | it('update a g0ver', async () => { 48 | const sample = _.sample(data); 49 | 50 | query.mockClear(); 51 | query.mockReturnValueOnce(Promise.resolve([{ id: sample.id, username: sample.username }])); 52 | const result = await graphql(Schema, request(sample)); 53 | expect(result.errors).toBeUndefined(); 54 | expect(sqlparser(query, 0)).toEqual({ username: sample.username, limit: 1, table: 'g0ver', method: 'select' }); 55 | expect(sqlparser(query, 1)).toEqual({ 56 | id: sample.id, 57 | username: sample.username, 58 | skill: JSON.stringify(sample.skill), 59 | updated_at: true, 60 | table: 'g0ver', 61 | method: 'update', 62 | }); 63 | const g0ver = _.get(result, 'data.updateG0ver'); 64 | expect(g0ver).toEqual({ 65 | clientMutationId: null, 66 | g0ver: { username: sample.username }, 67 | }); 68 | }); 69 | 70 | it('noop to update any g0ver', async () => { 71 | const sample = _.sample(data); 72 | 73 | query.mockClear(); 74 | query.mockReturnValueOnce(Promise.resolve([{ id: sample.id, username: sample.username }])); 75 | const result = await graphql(Schema, request({ username: sample.username })); 76 | expect(result.errors).toBeUndefined(); 77 | expect(sqlparser(query, 0)).toEqual({ username: sample.username, limit: 1, table: 'g0ver', method: 'select' }); 78 | expect(sqlparser(query, 1)).toEqual({ 79 | id: sample.id, 80 | username: sample.username, 81 | updated_at: true, 82 | table: 'g0ver', 83 | method: 'update', 84 | }); 85 | const g0ver = _.get(result, 'data.updateG0ver'); 86 | expect(g0ver).toEqual({ 87 | clientMutationId: null, 88 | g0ver: { username: sample.username }, 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/command/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import XRegExp from 'xregexp'; 3 | import Slack from '../Slack'; 4 | import G0ver from '../model/G0ver'; 5 | import Interaction from '../model/Interaction'; 6 | import cmdHelp from './help'; 7 | import cmdIn from './in'; 8 | import cmdOut from './out'; 9 | import cmdAll from './all'; 10 | import cmdAdd from './add'; 11 | import cmdDel from './del'; 12 | import cmdCreate from './create'; 13 | import cmdRemove from './remove'; 14 | import cmdProjects from './projects'; 15 | import cmdJothon from './jothon'; 16 | import cmdCancel from './cancel'; 17 | import cmdEvents from './events'; 18 | import cmdFollow from './follow'; 19 | import cmdUnfollow from './unfollow'; 20 | import cmdNotice from './notice'; 21 | import cmdWhoami from './whoami'; 22 | import cmdSearch from './search'; 23 | import cmdWhois from './whois'; 24 | import cmdSlogan from './slogan'; 25 | 26 | const qna = [ 27 | { handler: cmdIn, regexp: new XRegExp('^in(?<value>.*)', 'i') }, 28 | { handler: cmdOut, regexp: /^out/i }, 29 | { handler: cmdAll, regexp: /^all/i }, 30 | { handler: cmdAdd, regexp: new XRegExp('^add (?<hashtag>.+)', 'i') }, 31 | { handler: cmdDel, regexp: new XRegExp('^del (?<hashtag>.+)', 'i') }, 32 | { handler: cmdCreate, regexp: new XRegExp('^create (?<title>.+)', 'i') }, 33 | { handler: cmdRemove, regexp: new XRegExp('remove(?<value>.*)', 'i') }, 34 | { handler: cmdProjects, regexp: /^projects/i }, 35 | { handler: cmdJothon, regexp: new XRegExp('^jothon (?<title>.+)', 'i') }, 36 | { handler: cmdCancel, regexp: new XRegExp('^cancel(?<value>.*)', 'i') }, 37 | { handler: cmdEvents, regexp: /^events/i }, 38 | { handler: cmdFollow, regexp: new XRegExp('^follow(?<value>.*)', 'i') }, 39 | { handler: cmdUnfollow, regexp: new XRegExp('^unfollow(?<value>.*)', 'i') }, 40 | { handler: cmdNotice, regexp: new XRegExp('^notice(?<value>.*)', 'i') }, 41 | { handler: cmdWhoami, regexp: /^whoami/i }, 42 | { handler: cmdSearch, regexp: new XRegExp('^search (?<keyword>.+)', 'i') }, 43 | { handler: cmdWhois, regexp: new XRegExp('^whois <@(?<user>[^>]+)>', 'i') }, 44 | { handler: cmdSlogan, regexp: new XRegExp('^slogan (?<slogan>.+)', 'i') }, 45 | ]; 46 | 47 | export default async function (data) { 48 | const { channel, user, text } = data; 49 | const message = _.trim(text); 50 | 51 | let index = 0; 52 | let answer; 53 | 54 | if (['bye', 'exit'].indexOf(message) > -1) { 55 | answer = 'Bye, miss U.'; 56 | Interaction.delete(user); 57 | } 58 | 59 | const interaction = Interaction.get(user); 60 | if (interaction) { 61 | Interaction.delete(user); 62 | answer = await interaction(data); 63 | } 64 | 65 | while (!answer && index < qna.length) { 66 | const { handler, regexp } = qna[index]; 67 | const match = XRegExp.exec(message, regexp); 68 | if (match) answer = await handler(match, data); 69 | index += 1; 70 | } 71 | 72 | if (!answer) answer = await cmdHelp(); 73 | 74 | if (_.isPlainObject(answer)) { 75 | Slack.postMessage({ channel, ...answer }); 76 | return; 77 | } 78 | 79 | const reply = await Slack.postMessage({ channel, text: answer }); 80 | if (reply && /^D.+$/.test(reply.channel)) { 81 | const g0ver = await G0ver.load(user) || await new G0ver({ id: user }).insert(); 82 | if (g0ver.channel) { 83 | g0ver.channel = reply.channel; 84 | await g0ver.save(); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/command/__tests__/__snapshots__/help.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`help command default 1`] = ` 4 | Array [ 5 | Array [ 6 | Object { 7 | "attachments": Array [ 8 | Object { 9 | "color": "#000", 10 | "mrkdwn_in": Array [ 11 | "text", 12 | "pretext", 13 | "fields", 14 | ], 15 | "text": "*關鍵字 技能 議題* 16 | \`add\`, \`del\`, \`whoami\` 17 | \`add <hashtag>\` 新增關鍵字,逗號可以多筆 ( ex. add A, B ) 18 | \`del <hashtag>\` 刪除關鍵字,逗號可以多筆 ( ex. del A, B ) 19 | \`whoami\` 查詢 自己 登錄了哪些關鍵字", 20 | }, 21 | Object { 22 | "color": "#000", 23 | "mrkdwn_in": Array [ 24 | "text", 25 | "pretext", 26 | "fields", 27 | ], 28 | "text": "*挖坑 找坑* 29 | \`create\`, \`remove\`, \`projects\` 30 | \`create <title>\` 挖坑, title 為專案名稱 31 | \`remove [title]\` 清坑, title ( 選填 ) 為專案名稱 32 | \`projects\` 列出所有的坑", 33 | }, 34 | Object { 35 | "color": "#000", 36 | "mrkdwn_in": Array [ 37 | "text", 38 | "pretext", 39 | "fields", 40 | ], 41 | "text": "*推坑 找人* 42 | \`whois\`, \`search\` 43 | \`whois <@username>\` 查詢 g0ver 登錄了哪些關鍵字 ( ex. whois @yutin ) 44 | \`search <hashtag>\` *搜尋哪些 g0ver 有此關鍵字*", 45 | }, 46 | Object { 47 | "color": "#000", 48 | "mrkdwn_in": Array [ 49 | "text", 50 | "pretext", 51 | "fields", 52 | ], 53 | "text": "*入坑 出坑* 54 | \`in\`, \`all\`, \`out\` 55 | \`in [note] [hours]\` 進入某個坑裡,hours ( 選填 ) 預計幾小時後出坑 56 | \`out\` 離開某個坑 57 | \`all\` 顯示 g0ver 正在哪些坑,也許他們需要你的加入", 58 | }, 59 | Object { 60 | "color": "#000", 61 | "mrkdwn_in": Array [ 62 | "text", 63 | "pretext", 64 | "fields", 65 | ], 66 | "text": "*揪松 找松* 67 | \`jothon\`, \`cancel\`, \`events\`, \`follow\`, \`unfollow\`, \`notice\` 68 | \`jothon <title>\` 揪松, title 為活動名稱 69 | \`cancel [title]\` 取消活動, title ( 選填 ) 為活動名稱 70 | \`events\` 列出即將到來的大松小松", 71 | }, 72 | Object { 73 | "color": "#000", 74 | "mrkdwn_in": Array [ 75 | "text", 76 | "pretext", 77 | "fields", 78 | ], 79 | "text": "*追蹤專案 追蹤活動* 80 | \`follow\`, \`unfollow\` 81 | \`follow [title]\` 追蹤, title ( 選填 ) 為專案或活動名稱 82 | \`unfollow [title]\` 取消追蹤, title ( 選填 ) 為專案或活動名稱 83 | \`notice <message>\` 發訊息通知關注者, message 為訊息內容", 84 | }, 85 | Object { 86 | "color": "#000", 87 | "mrkdwn_in": Array [ 88 | "text", 89 | "pretext", 90 | "fields", 91 | ], 92 | "text": "*座右銘* 93 | \`slogan\` 94 | \`slogan <text>\` 持續關注的議題、來自哪裡 或 URL 讓大家認識你", 95 | }, 96 | Object { 97 | "color": "#000", 98 | "mrkdwn_in": Array [ 99 | "text", 100 | "pretext", 101 | "fields", 102 | ], 103 | "text": "*了解更多* 104 | <http://g0v.tw/zh-TW/manifesto.html|g0v 宣言> - <https://g0v-jothon.kktix.cc/|g0v 揪松團> - <https://github.com/g0v|g0v Github>", 105 | }, 106 | ], 107 | "channel": "D100", 108 | "text": "歡迎來到 g0v。 社群是自主參與,成果開放 109 | 私訊 <@g0ver> 使用下列指令,當需要 *技能* 時可以找到沒有人,也可以發現 *適合入的坑* ,或者輸入 \`search g0v大使\` 這些人都願意推你入坑。", 110 | }, 111 | ], 112 | ] 113 | `; 114 | -------------------------------------------------------------------------------- /src/command/__tests__/cancel.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('cancel command', () => { 6 | describe('cancel <name>', () => { 7 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'cancel 基礎建設松' }; 8 | 9 | it('when event does not exist', async () => { 10 | client.mockReturnValueOnce([]); 11 | 12 | await index(data); 13 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 14 | channel: 'D100', 15 | text: 'Sorry! 找不到活動', 16 | }); 17 | 18 | expect(client).toHaveBeenCalledTimes(1); 19 | expect(client).toMatchSnapshot(); 20 | }); 21 | 22 | it('permission denied', async () => { 23 | client.mockReturnValueOnce([{ id: '5', userId: 'U03B2AB00' }]); 24 | 25 | await index(data); 26 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 27 | channel: 'D100', 28 | text: 'Sorry! 沒有權限取消 基礎建設松 活動', 29 | }); 30 | 31 | expect(client).toHaveBeenCalledTimes(1); 32 | expect(client).toMatchSnapshot(); 33 | }); 34 | 35 | it('successfully deleted', async () => { 36 | client.mockReturnValueOnce([{ id: '5', userId: 'U03B2AB13' }]); 37 | client.mockReturnValueOnce([]); 38 | 39 | await index(data); 40 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 41 | channel: 'D100', 42 | text: 'Done, 取消 基礎建設松 活動', 43 | }); 44 | 45 | expect(client).toHaveBeenCalledTimes(2); 46 | expect(client).toMatchSnapshot(); 47 | }); 48 | }); 49 | 50 | describe('remove [name]', () => { 51 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'cancel' }; 52 | 53 | it('when event does not exist', async () => { 54 | client.mockReturnValueOnce([]); 55 | 56 | await index(data); 57 | 58 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 59 | channel: 'D100', 60 | text: 'Sorry! 找不到活動,快來揪松吧', 61 | }); 62 | 63 | expect(client).toHaveBeenCalledTimes(1); 64 | expect(client).toMatchSnapshot(); 65 | }); 66 | 67 | describe('when event is existed', () => { 68 | beforeEach(async () => { 69 | client.mockReturnValueOnce([ 70 | { id: '3', title: '基礎建設松' }, 71 | { id: '5', title: 'event 0001' }, 72 | { id: '6', title: 'event 0002' }, 73 | { id: '9', title: 'event 0003' }, 74 | ]); 75 | 76 | await index(data); 77 | }); 78 | 79 | it('when not found event', async () => { 80 | await index({ ...data, text: '0' }); 81 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 82 | channel: 'D100', 83 | text: 'Sorry! 找不到活動', 84 | }); 85 | 86 | expect(client).toHaveBeenCalledTimes(1); 87 | expect(client).toMatchSnapshot(); 88 | }); 89 | 90 | it('successfully deleted', async () => { 91 | client.mockReturnValueOnce([]); 92 | 93 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 94 | channel: 'D100', 95 | text: expect.stringContaining('請選擇要取消的活動(輸入代碼)?'), 96 | }); 97 | 98 | await index({ ...data, text: '1' }); 99 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 100 | channel: 'D100', 101 | text: 'Done, 取消 基礎建設松 活動', 102 | }); 103 | 104 | expect(client).toHaveBeenCalledTimes(2); 105 | expect(client).toMatchSnapshot(); 106 | }); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/command/__tests__/remove.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('remove command', () => { 6 | describe('remove <title>', () => { 7 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'remove g0ver box' }; 8 | 9 | it('when project does not exist', async () => { 10 | client.mockReturnValueOnce([]); 11 | 12 | await index(data); 13 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 14 | channel: 'D100', 15 | text: 'Sorry! 找不到專案', 16 | }); 17 | 18 | expect(client).toHaveBeenCalledTimes(1); 19 | expect(client).toMatchSnapshot(); 20 | }); 21 | 22 | it('permission denied', async () => { 23 | client.mockReturnValueOnce([{ id: '5', userId: 'U03B2AB00' }]); 24 | 25 | await index(data); 26 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 27 | channel: 'D100', 28 | text: 'Sorry! 沒有權限刪除 g0ver box 專案', 29 | }); 30 | 31 | expect(client).toHaveBeenCalledTimes(1); 32 | expect(client).toMatchSnapshot(); 33 | }); 34 | 35 | it('successfully deleted', async () => { 36 | client.mockReturnValueOnce([{ id: '5', userId: 'U03B2AB13' }]); 37 | client.mockReturnValueOnce([]); 38 | 39 | await index(data); 40 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 41 | channel: 'D100', 42 | text: 'Done, 刪除 g0ver box 專案', 43 | }); 44 | 45 | expect(client).toHaveBeenCalledTimes(2); 46 | expect(client).toMatchSnapshot(); 47 | }); 48 | }); 49 | 50 | describe('remove [title]', () => { 51 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'remove' }; 52 | 53 | it('when project does not exist', async () => { 54 | client.mockReturnValueOnce([]); 55 | 56 | await index(data); 57 | 58 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 59 | channel: 'D100', 60 | text: 'Sorry! 找不到坑,快來挖坑吧', 61 | }); 62 | 63 | expect(client).toHaveBeenCalledTimes(1); 64 | expect(client).toMatchSnapshot(); 65 | }); 66 | 67 | describe('when project is existed', () => { 68 | beforeEach(async () => { 69 | client.mockReturnValueOnce([ 70 | { id: '3', title: 'g0ver box' }, 71 | { id: '5', title: 'project 0001' }, 72 | { id: '6', title: 'project 0002' }, 73 | { id: '9', title: 'project 0003' }, 74 | ]); 75 | 76 | await index(data); 77 | }); 78 | 79 | it('when not found project', async () => { 80 | await index({ ...data, text: '0' }); 81 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 82 | channel: 'D100', 83 | text: 'Sorry! 找不到專案', 84 | }); 85 | 86 | expect(client).toHaveBeenCalledTimes(1); 87 | expect(client).toMatchSnapshot(); 88 | }); 89 | 90 | it('successfully deleted', async () => { 91 | client.mockReturnValueOnce([]); 92 | 93 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 94 | channel: 'D100', 95 | text: expect.stringContaining('請選擇要刪除的專案(輸入代碼)?'), 96 | }); 97 | 98 | await index({ ...data, text: '1' }); 99 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 100 | channel: 'D100', 101 | text: 'Done, 刪除 g0ver box 專案', 102 | }); 103 | 104 | expect(client).toHaveBeenCalledTimes(2); 105 | expect(client).toMatchSnapshot(); 106 | }); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/command/__tests__/in.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('in command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'in' }; 7 | 8 | describe('in [note] [hours]', () => { 9 | it('when no found projects', async () => { 10 | client.mockReturnValueOnce([]); 11 | client.mockReturnValueOnce([]); 12 | client.mockReturnValueOnce([]); 13 | 14 | await index(data); 15 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 16 | channel: 'D100', text: '是否為此次目標做個日誌?\n`(輸入 skip 可跳過)`', 17 | }); 18 | 19 | await index({ ...data, text: 'ya! a note' }); 20 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 21 | channel: 'D100', text: 'Done, in ya! a note 8 hr', 22 | }); 23 | 24 | expect(client).toMatchSnapshot(); 25 | expect(client).toHaveBeenCalledTimes(3); 26 | }); 27 | 28 | it('when found projects', async () => { 29 | client.mockReturnValueOnce([ 30 | { id: '3', title: 'g0ver box' }, 31 | { id: '5', title: 'project 001' }, 32 | ]); 33 | 34 | await index(data); 35 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 36 | channel: 'D100', text: expect.stringContaining('請問是否為下列專案(輸入代碼)?'), 37 | }); 38 | 39 | await index({ ...data, text: '1' }); 40 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 41 | channel: 'D100', text: '是否為此次目標做個日誌?\n`(輸入 skip 可跳過)`', 42 | }); 43 | 44 | await index({ ...data, text: 'exit' }); 45 | expect(client).toMatchSnapshot(); 46 | expect(client).toHaveBeenCalledTimes(1); 47 | }); 48 | 49 | describe('input note', () => { 50 | beforeEach(async () => { 51 | client.mockReturnValueOnce([ 52 | { id: '3', title: 'g0ver box' }, 53 | { id: '5', title: 'project 001' }, 54 | ]); 55 | 56 | await index(data); 57 | await index({ ...data, text: '1' }); 58 | }); 59 | 60 | it('when has note', async () => { 61 | client.mockReturnValueOnce([]); 62 | client.mockReturnValueOnce(['5']); 63 | await index({ ...data, text: 'note information' }); 64 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 65 | channel: 'D100', text: 'Done, in note information 8 hr', 66 | }); 67 | 68 | expect(client).toMatchSnapshot(); 69 | expect(client).toHaveBeenCalledTimes(3); 70 | }); 71 | 72 | it('when hasn\'t note', async () => { 73 | client.mockReturnValueOnce([]); 74 | client.mockReturnValueOnce(['5']); 75 | await index({ ...data, text: 'skip' }); 76 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 77 | channel: 'D100', text: 'Done, in 8 hr', 78 | }); 79 | 80 | expect(client).toMatchSnapshot(); 81 | expect(client).toHaveBeenCalledTimes(3); 82 | }); 83 | }); 84 | }); 85 | 86 | it('in <note> [hours]', async () => { 87 | client.mockReturnValueOnce([]); 88 | await index({ ...data, text: 'in fix issue' }); 89 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 90 | channel: 'D100', text: 'Done, in fix issue 8 hr', 91 | }); 92 | 93 | expect(client).toMatchSnapshot(); 94 | expect(client).toHaveBeenCalledTimes(3); 95 | }); 96 | 97 | it('in <note> <hours>', async () => { 98 | client.mockReturnValueOnce([]); 99 | await index({ ...data, text: 'in fix issue 4' }); 100 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 101 | channel: 'D100', text: 'Done, in fix issue 4 hr', 102 | }); 103 | 104 | expect(client).toMatchSnapshot(); 105 | expect(client).toHaveBeenCalledTimes(3); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/command/__tests__/follow.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('follow command', () => { 6 | describe('follow <title>', () => { 7 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'follow g0ver box' }; 8 | 9 | it('when project and event does not exist', async () => { 10 | client.mockReturnValueOnce([]); 11 | client.mockReturnValueOnce([]); 12 | 13 | await index(data); 14 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 15 | channel: 'D100', 16 | text: 'Sorry! 找不到專案或活動', 17 | }); 18 | 19 | expect(client).toMatchSnapshot(); 20 | expect(client).toHaveBeenCalledTimes(2); 21 | }); 22 | 23 | it('successfully follow when has event', async () => { 24 | client.mockReturnValueOnce([{ id: '5', title: 'g0ver box' }]); 25 | client.mockReturnValueOnce([]); 26 | 27 | await index(data); 28 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 29 | channel: 'D100', 30 | text: 'Done, 追蹤 g0ver box 活動', 31 | }); 32 | 33 | expect(client).toMatchSnapshot(); 34 | expect(client).toHaveBeenCalledTimes(2); 35 | }); 36 | 37 | it('successfully follow when has project', async () => { 38 | client.mockReturnValueOnce([]); 39 | client.mockReturnValueOnce([{ id: '5', title: 'g0ver box' }]); 40 | client.mockReturnValueOnce([]); 41 | 42 | await index(data); 43 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 44 | channel: 'D100', 45 | text: 'Done, 追蹤 g0ver box 專案', 46 | }); 47 | 48 | expect(client).toMatchSnapshot(); 49 | expect(client).toHaveBeenCalledTimes(3); 50 | }); 51 | }); 52 | 53 | describe('follow [title]', () => { 54 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'follow' }; 55 | 56 | it('when project and event does not exist', async () => { 57 | client.mockReturnValueOnce([]); 58 | client.mockReturnValueOnce([]); 59 | 60 | await index(data); 61 | 62 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 63 | channel: 'D100', 64 | text: 'Sorry! 找不到未追蹤的專案或活動', 65 | }); 66 | 67 | expect(client).toMatchSnapshot(); 68 | expect(client).toHaveBeenCalledTimes(2); 69 | }); 70 | 71 | describe('when project or event is existed', () => { 72 | beforeEach(async () => { 73 | client.mockReturnValueOnce([{ id: '5', title: 'project title' }]); 74 | client.mockReturnValueOnce([{ id: '5', title: 'event title' }]); 75 | 76 | await index(data); 77 | }); 78 | 79 | it('successfully follow project', async () => { 80 | client.mockReturnValueOnce([]); 81 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 82 | channel: 'D100', 83 | text: expect.stringContaining('請選擇要追蹤的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`'), 84 | }); 85 | 86 | await index({ ...data, text: '1' }); 87 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 88 | channel: 'D100', 89 | text: expect.stringContaining('Done, 追蹤 project title 活動'), 90 | }); 91 | 92 | expect(client).toMatchSnapshot(); 93 | expect(client).toHaveBeenCalledTimes(3); 94 | }); 95 | 96 | it('successfully follow events', async () => { 97 | client.mockReturnValueOnce([]); 98 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 99 | channel: 'D100', 100 | text: expect.stringContaining('請選擇要追蹤的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`'), 101 | }); 102 | 103 | await index({ ...data, text: '2' }); 104 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 105 | channel: 'D100', 106 | text: expect.stringContaining('Done, 追蹤 event title 專案'), 107 | }); 108 | 109 | expect(client).toMatchSnapshot(); 110 | expect(client).toHaveBeenCalledTimes(3); 111 | }); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/command/__tests__/unfollow.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('unfollow command', () => { 6 | describe('unfollow <title>', () => { 7 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'unfollow g0ver box' }; 8 | 9 | it('when project and event does not exist', async () => { 10 | client.mockReturnValueOnce([]); 11 | client.mockReturnValueOnce([]); 12 | 13 | await index(data); 14 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 15 | channel: 'D100', 16 | text: 'Sorry! 找不到專案或活動', 17 | }); 18 | 19 | expect(client).toMatchSnapshot(); 20 | expect(client).toHaveBeenCalledTimes(2); 21 | }); 22 | 23 | it('successfully unfollow when has event', async () => { 24 | client.mockReturnValueOnce([{ id: '5', title: 'g0ver box' }]); 25 | client.mockReturnValueOnce([]); 26 | 27 | await index(data); 28 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 29 | channel: 'D100', 30 | text: 'Done, 取消追蹤 g0ver box 活動', 31 | }); 32 | 33 | expect(client).toMatchSnapshot(); 34 | expect(client).toHaveBeenCalledTimes(2); 35 | }); 36 | 37 | it('successfully unfollow when has project', async () => { 38 | client.mockReturnValueOnce([]); 39 | client.mockReturnValueOnce([{ id: '5', title: 'g0ver box' }]); 40 | client.mockReturnValueOnce([]); 41 | 42 | await index(data); 43 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 44 | channel: 'D100', 45 | text: 'Done, 取消追蹤 g0ver box 專案', 46 | }); 47 | 48 | expect(client).toMatchSnapshot(); 49 | expect(client).toHaveBeenCalledTimes(3); 50 | }); 51 | }); 52 | 53 | describe('unfollow [title]', () => { 54 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'unfollow' }; 55 | 56 | it('when project and event does not exist', async () => { 57 | client.mockReturnValueOnce([]); 58 | client.mockReturnValueOnce([]); 59 | 60 | await index(data); 61 | 62 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 63 | channel: 'D100', 64 | text: 'Sorry! 找不到已追蹤的專案或活動', 65 | }); 66 | 67 | expect(client).toMatchSnapshot(); 68 | expect(client).toHaveBeenCalledTimes(2); 69 | }); 70 | 71 | describe('when project or event is existed', () => { 72 | beforeEach(async () => { 73 | client.mockReturnValueOnce([{ id: '5', title: 'project title' }]); 74 | client.mockReturnValueOnce([{ id: '5', title: 'event title' }]); 75 | 76 | await index(data); 77 | }); 78 | 79 | it('successfully unfollow project', async () => { 80 | client.mockReturnValueOnce([]); 81 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 82 | channel: 'D100', 83 | text: expect.stringContaining('請選擇要取消追蹤的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`'), 84 | }); 85 | 86 | await index({ ...data, text: '1' }); 87 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 88 | channel: 'D100', 89 | text: expect.stringContaining('Done, 取消追蹤 project title 活動'), 90 | }); 91 | 92 | expect(client).toMatchSnapshot(); 93 | expect(client).toHaveBeenCalledTimes(3); 94 | }); 95 | 96 | it('successfully unfollow events', async () => { 97 | client.mockReturnValueOnce([]); 98 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 99 | channel: 'D100', 100 | text: expect.stringContaining('請選擇要取消追蹤的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`'), 101 | }); 102 | 103 | await index({ ...data, text: '2' }); 104 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 105 | channel: 'D100', 106 | text: expect.stringContaining('Done, 取消追蹤 event title 專案'), 107 | }); 108 | 109 | expect(client).toMatchSnapshot(); 110 | expect(client).toHaveBeenCalledTimes(3); 111 | }); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/command/__tests__/jothon.test.js: -------------------------------------------------------------------------------- 1 | import { client } from 'knex'; 2 | import Slack from '../../Slack'; 3 | import index from '../'; 4 | 5 | describe('jothon command', () => { 6 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'jothon 基礎建設松' }; 7 | 8 | it('when event does not exist', async () => { 9 | client.mockReturnValueOnce([]); 10 | client.mockReturnValueOnce(['5']); 11 | 12 | await index(data); 13 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 14 | channel: 'D100', 15 | text: '請輸入 RFC2822 或 ISO8601 格式的 活動時間 預設為 +08 時區 (ex. 12-31 23:00+08)?\n`(輸入 exit 可離開)`', 16 | }); 17 | 18 | await index({ ...data, text: '12-31 14:00' }); 19 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 20 | channel: 'D100', 21 | text: '請輸入 活動資訊 / 報名 連結?\n`(輸入 skip 可跳過)` `(輸入 exit 可離開)`', 22 | }); 23 | 24 | await index({ ...data, text: 'https://events.g0v.tw/' }); 25 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 26 | channel: 'D100', 27 | text: '請確認資料如下?(輸入 yes 完成建立)\n`(輸入 exit 可離開)`', 28 | })); 29 | 30 | await index({ ...data, text: 'yes' }); 31 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 32 | channel: 'D100', 33 | text: 'Done, jothon 基礎建設松', 34 | }); 35 | 36 | expect(client).toMatchSnapshot(); 37 | }); 38 | 39 | it('when event is existed', async () => { 40 | client.mockReturnValueOnce([{ id: 5 }]); 41 | 42 | await index(data); 43 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 44 | channel: 'D100', 45 | text: 'Sorry! 發現相同的活動名稱', 46 | }); 47 | 48 | expect(client).toMatchSnapshot(); 49 | }); 50 | 51 | it('when use skip', async () => { 52 | client.mockReturnValueOnce([]); 53 | await index(data); 54 | await index({ ...data, text: '12-31 16' }); 55 | await index({ ...data, text: 'skip' }); 56 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 57 | channel: 'D100', 58 | text: '請確認資料如下?(輸入 yes 完成建立)\n`(輸入 exit 可離開)`', 59 | })); 60 | await index({ ...data, text: 'skip' }); 61 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 62 | channel: 'D100', 63 | text: '無法判斷的指令', 64 | })); 65 | await index({ ...data, text: 'exit' }); 66 | }); 67 | 68 | describe('datetime', () => { 69 | beforeEach(async () => { 70 | client.mockReturnValueOnce([]); 71 | 72 | await index(data); 73 | }); 74 | 75 | it('12-31 16', async () => { 76 | await index({ ...data, text: '12-31 16' }); 77 | await index({ ...data, text: 'https://events.g0v.tw/' }); 78 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 79 | attachments: expect.arrayContaining([expect.objectContaining({ 80 | color: '#000', 81 | text: expect.stringContaining('1514707200000'), 82 | })]), 83 | })); 84 | await index({ ...data, text: 'exit' }); 85 | }); 86 | 87 | it('12-31 16+09', async () => { 88 | await index({ ...data, text: '12-31 16+09' }); 89 | await index({ ...data, text: 'https://events.g0v.tw/' }); 90 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 91 | attachments: expect.arrayContaining([expect.objectContaining({ 92 | color: '#000', 93 | text: expect.stringContaining('1514703600000'), 94 | })]), 95 | })); 96 | await index({ ...data, text: 'exit' }); 97 | }); 98 | 99 | it('2017-12-31 16+09', async () => { 100 | await index({ ...data, text: '2017-12-31 16+09' }); 101 | await index({ ...data, text: 'https://events.g0v.tw/' }); 102 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 103 | attachments: expect.arrayContaining([expect.objectContaining({ 104 | color: '#000', 105 | text: expect.stringContaining('1514703600000'), 106 | })]), 107 | })); 108 | await index({ ...data, text: 'exit' }); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/command/__tests__/notice.test.js: -------------------------------------------------------------------------------- 1 | /* eslint import/imports-first: 0 */ 2 | 3 | jest.mock('../../../knexfile', () => ({ 4 | client: 'pg', 5 | connection: { 6 | host: '127.0.0.1', 7 | user: 'postgres', 8 | password: null, 9 | database: 'g0ver_notice', 10 | }, 11 | })); 12 | 13 | import { client } from 'knex'; 14 | import Slack from '../../Slack'; 15 | import db from '../../model/Database'; 16 | import index from '../'; 17 | 18 | describe('notice command', () => { 19 | describe('notice <message>', () => { 20 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'notice 即將開放報名' }; 21 | 22 | it('when project and event does not exist', async () => { 23 | client.mockReturnValueOnce([]); 24 | client.mockReturnValueOnce([]); 25 | 26 | await index(data); 27 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 28 | channel: 'D100', 29 | text: 'Sorry! 找不到可發訊息通知的專案或活動', 30 | }); 31 | 32 | expect(client).toMatchSnapshot(); 33 | expect(client).toHaveBeenCalledTimes(2); 34 | }); 35 | 36 | it('successfully notice when has event', async () => { 37 | client.mockReturnValueOnce([{ id: '5', title: 'g0ver box', followerIds: ['U03B2AB13', 'U03B2AB00'] }]); 38 | client.mockReturnValueOnce([]); 39 | 40 | await index(data); 41 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 42 | channel: 'D100', 43 | text: expect.stringContaining('請選擇要發訊息通知的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`'), 44 | }); 45 | 46 | await index({ ...data, text: '1' }); 47 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 48 | channel: 'D100', 49 | text: expect.stringContaining('請確認發送訊息通知的內容如下?(輸入 yes 立即發送)\n`(輸入 exit 可離開)`'), 50 | }); 51 | 52 | await index({ ...data, text: 'yes' }); 53 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 54 | channel: 'D100', 55 | text: 'Done, 發送 g0ver box 活動通知', 56 | })); 57 | 58 | expect(Slack.postMultiMessage).toHaveBeenLastCalledWith( 59 | ['U03B2AB13', 'U03B2AB00'], 60 | '即將開放報名', 61 | ); 62 | 63 | expect(client).toMatchSnapshot(); 64 | expect(client).toHaveBeenCalledTimes(2); 65 | }); 66 | 67 | it('successfully notice when has project', async () => { 68 | client.mockReturnValueOnce([]); 69 | client.mockReturnValueOnce([{ id: '5', title: 'g0ver box', followerIds: ['U03B2AB13', 'U03B2AB00'] }]); 70 | 71 | await index(data); 72 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 73 | channel: 'D100', 74 | text: expect.stringContaining('請選擇要發訊息通知的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`'), 75 | }); 76 | 77 | await index({ ...data, text: '1' }); 78 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 79 | channel: 'D100', 80 | text: expect.stringContaining('請確認發送訊息通知的內容如下?(輸入 yes 立即發送)\n`(輸入 exit 可離開)`'), 81 | }); 82 | 83 | await index({ ...data, text: 'yes' }); 84 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 85 | channel: 'D100', 86 | text: 'Done, 發送 g0ver box 專案通知', 87 | })); 88 | 89 | expect(Slack.postMultiMessage).toHaveBeenLastCalledWith( 90 | ['U03B2AB13', 'U03B2AB00'], 91 | '即將開放報名', 92 | ); 93 | 94 | expect(client).toMatchSnapshot(); 95 | expect(client).toHaveBeenCalledTimes(2); 96 | }); 97 | }); 98 | 99 | it('notie [message]', async () => { 100 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin', text: 'notice' }; 101 | 102 | client.mockReturnValueOnce([{ id: '5', title: 'g0ver box', followerIds: ['U03B2AB13', 'U03B2AB00'] }]); 103 | client.mockReturnValueOnce([]); 104 | 105 | await index(data); 106 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 107 | channel: 'D100', 108 | text: expect.stringContaining('請選擇要發訊息通知的專案或活動(輸入代碼)?\n`(輸入 exit 可離開)`'), 109 | }); 110 | 111 | await index({ ...data, text: '1' }); 112 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 113 | channel: 'D100', 114 | text: '請輸入發送訊息通知的內容?\n`(輸入 exit 可離開)`', 115 | }); 116 | 117 | await index({ ...data, text: '系統將在週末更新' }); 118 | expect(Slack.postMessage).toHaveBeenLastCalledWith({ 119 | channel: 'D100', 120 | text: expect.stringContaining('請確認發送訊息通知的內容如下?(輸入 yes 立即發送)\n`(輸入 exit 可離開)`'), 121 | }); 122 | 123 | await index({ ...data, text: 'yes' }); 124 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 125 | channel: 'D100', 126 | text: 'Done, 發送 g0ver box 活動通知', 127 | })); 128 | 129 | expect(Slack.postMultiMessage).toHaveBeenLastCalledWith( 130 | ['U03B2AB13', 'U03B2AB00'], 131 | '系統將在週末更新', 132 | ); 133 | 134 | expect(client).toMatchSnapshot(); 135 | expect(client).toHaveBeenCalledTimes(2); 136 | }); 137 | 138 | describe('database', () => { 139 | const data = { channel: 'D100', user: 'U03B2AB13', name: 'yutin' }; 140 | 141 | beforeAll(async () => { 142 | await db('pg_tables').select('tablename').where('schemaname', 'public').map(({ tablename }) => ( 143 | db.schema.dropTable(tablename) 144 | )); 145 | await db.migrate.latest(); 146 | await index({ ...data, text: 'create g0ver box' }); 147 | await index({ ...data, text: 'skip' }); 148 | await index({ ...data, text: 'skip' }); 149 | await index({ ...data, text: 'skip' }); 150 | await index({ ...data, text: 'yes' }); 151 | }); 152 | 153 | afterAll(async () => { 154 | await db.destroy(); 155 | }); 156 | 157 | it('follow', async () => { 158 | await index({ ...data, text: 'follow' }); 159 | await index({ ...data, text: '1' }); 160 | expect(client).toMatchSnapshot(); 161 | expect(client).toHaveBeenCalledTimes(3); 162 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 163 | channel: 'D100', 164 | text: 'Done, 追蹤 g0ver box 專案', 165 | })); 166 | 167 | await index({ ...data, text: 'follow' }); 168 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 169 | channel: 'D100', 170 | text: 'Sorry! 找不到未追蹤的專案或活動', 171 | })); 172 | }); 173 | 174 | it('notice', async () => { 175 | await index({ ...data, text: 'notice 將於週末更新系統' }); 176 | await index({ ...data, text: '1' }); 177 | await index({ ...data, text: 'yes' }); 178 | expect(client).toMatchSnapshot(); 179 | expect(client).toHaveBeenCalledTimes(2); 180 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 181 | channel: 'D100', 182 | text: 'Done, 發送 g0ver box 專案通知', 183 | })); 184 | 185 | await index({ ...data, text: 'follow' }); 186 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 187 | channel: 'D100', 188 | text: 'Sorry! 找不到未追蹤的專案或活動', 189 | })); 190 | }); 191 | 192 | it('unfollow', async () => { 193 | await index({ ...data, text: 'unfollow' }); 194 | await index({ ...data, text: '1' }); 195 | expect(client).toMatchSnapshot(); 196 | expect(client).toHaveBeenCalledTimes(3); 197 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 198 | channel: 'D100', 199 | text: 'Done, 取消追蹤 g0ver box 專案', 200 | })); 201 | 202 | await index({ ...data, text: 'unfollow' }); 203 | expect(Slack.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ 204 | channel: 'D100', 205 | text: 'Sorry! 找不到已追蹤的專案或活動', 206 | })); 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "__schema": { 4 | "queryType": { 5 | "name": "Query" 6 | }, 7 | "mutationType": { 8 | "name": "Mutation" 9 | }, 10 | "subscriptionType": null, 11 | "types": [ 12 | { 13 | "kind": "OBJECT", 14 | "name": "Query", 15 | "description": null, 16 | "fields": [ 17 | { 18 | "name": "g0ver", 19 | "description": null, 20 | "args": [ 21 | { 22 | "name": "id", 23 | "description": null, 24 | "type": { 25 | "kind": "SCALAR", 26 | "name": "Primary", 27 | "ofType": null 28 | }, 29 | "defaultValue": null 30 | }, 31 | { 32 | "name": "username", 33 | "description": null, 34 | "type": { 35 | "kind": "SCALAR", 36 | "name": "ID", 37 | "ofType": null 38 | }, 39 | "defaultValue": null 40 | }, 41 | { 42 | "name": "after", 43 | "description": null, 44 | "type": { 45 | "kind": "SCALAR", 46 | "name": "String", 47 | "ofType": null 48 | }, 49 | "defaultValue": null 50 | }, 51 | { 52 | "name": "first", 53 | "description": null, 54 | "type": { 55 | "kind": "SCALAR", 56 | "name": "Int", 57 | "ofType": null 58 | }, 59 | "defaultValue": null 60 | }, 61 | { 62 | "name": "before", 63 | "description": null, 64 | "type": { 65 | "kind": "SCALAR", 66 | "name": "String", 67 | "ofType": null 68 | }, 69 | "defaultValue": null 70 | }, 71 | { 72 | "name": "last", 73 | "description": null, 74 | "type": { 75 | "kind": "SCALAR", 76 | "name": "Int", 77 | "ofType": null 78 | }, 79 | "defaultValue": null 80 | } 81 | ], 82 | "type": { 83 | "kind": "OBJECT", 84 | "name": "G0verConnection", 85 | "ofType": null 86 | }, 87 | "isDeprecated": false, 88 | "deprecationReason": null 89 | }, 90 | { 91 | "name": "project", 92 | "description": null, 93 | "args": [ 94 | { 95 | "name": "id", 96 | "description": null, 97 | "type": { 98 | "kind": "SCALAR", 99 | "name": "Primary", 100 | "ofType": null 101 | }, 102 | "defaultValue": null 103 | }, 104 | { 105 | "name": "title", 106 | "description": null, 107 | "type": { 108 | "kind": "SCALAR", 109 | "name": "ID", 110 | "ofType": null 111 | }, 112 | "defaultValue": null 113 | }, 114 | { 115 | "name": "after", 116 | "description": null, 117 | "type": { 118 | "kind": "SCALAR", 119 | "name": "String", 120 | "ofType": null 121 | }, 122 | "defaultValue": null 123 | }, 124 | { 125 | "name": "first", 126 | "description": null, 127 | "type": { 128 | "kind": "SCALAR", 129 | "name": "Int", 130 | "ofType": null 131 | }, 132 | "defaultValue": null 133 | }, 134 | { 135 | "name": "before", 136 | "description": null, 137 | "type": { 138 | "kind": "SCALAR", 139 | "name": "String", 140 | "ofType": null 141 | }, 142 | "defaultValue": null 143 | }, 144 | { 145 | "name": "last", 146 | "description": null, 147 | "type": { 148 | "kind": "SCALAR", 149 | "name": "Int", 150 | "ofType": null 151 | }, 152 | "defaultValue": null 153 | } 154 | ], 155 | "type": { 156 | "kind": "OBJECT", 157 | "name": "ProjectConnection", 158 | "ofType": null 159 | }, 160 | "isDeprecated": false, 161 | "deprecationReason": null 162 | } 163 | ], 164 | "inputFields": null, 165 | "interfaces": [], 166 | "enumValues": null, 167 | "possibleTypes": null 168 | }, 169 | { 170 | "kind": "SCALAR", 171 | "name": "Primary", 172 | "description": null, 173 | "fields": null, 174 | "inputFields": null, 175 | "interfaces": null, 176 | "enumValues": null, 177 | "possibleTypes": null 178 | }, 179 | { 180 | "kind": "SCALAR", 181 | "name": "ID", 182 | "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", 183 | "fields": null, 184 | "inputFields": null, 185 | "interfaces": null, 186 | "enumValues": null, 187 | "possibleTypes": null 188 | }, 189 | { 190 | "kind": "SCALAR", 191 | "name": "String", 192 | "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", 193 | "fields": null, 194 | "inputFields": null, 195 | "interfaces": null, 196 | "enumValues": null, 197 | "possibleTypes": null 198 | }, 199 | { 200 | "kind": "SCALAR", 201 | "name": "Int", 202 | "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. ", 203 | "fields": null, 204 | "inputFields": null, 205 | "interfaces": null, 206 | "enumValues": null, 207 | "possibleTypes": null 208 | }, 209 | { 210 | "kind": "OBJECT", 211 | "name": "G0verConnection", 212 | "description": "A connection to a list of items.", 213 | "fields": [ 214 | { 215 | "name": "pageInfo", 216 | "description": "Information to aid in pagination.", 217 | "args": [], 218 | "type": { 219 | "kind": "NON_NULL", 220 | "name": null, 221 | "ofType": { 222 | "kind": "OBJECT", 223 | "name": "PageInfo", 224 | "ofType": null 225 | } 226 | }, 227 | "isDeprecated": false, 228 | "deprecationReason": null 229 | }, 230 | { 231 | "name": "edges", 232 | "description": "A list of edges.", 233 | "args": [], 234 | "type": { 235 | "kind": "LIST", 236 | "name": null, 237 | "ofType": { 238 | "kind": "OBJECT", 239 | "name": "G0verEdge", 240 | "ofType": null 241 | } 242 | }, 243 | "isDeprecated": false, 244 | "deprecationReason": null 245 | } 246 | ], 247 | "inputFields": null, 248 | "interfaces": [], 249 | "enumValues": null, 250 | "possibleTypes": null 251 | }, 252 | { 253 | "kind": "OBJECT", 254 | "name": "PageInfo", 255 | "description": "Information about pagination in a connection.", 256 | "fields": [ 257 | { 258 | "name": "hasNextPage", 259 | "description": "When paginating forwards, are there more items?", 260 | "args": [], 261 | "type": { 262 | "kind": "NON_NULL", 263 | "name": null, 264 | "ofType": { 265 | "kind": "SCALAR", 266 | "name": "Boolean", 267 | "ofType": null 268 | } 269 | }, 270 | "isDeprecated": false, 271 | "deprecationReason": null 272 | }, 273 | { 274 | "name": "hasPreviousPage", 275 | "description": "When paginating backwards, are there more items?", 276 | "args": [], 277 | "type": { 278 | "kind": "NON_NULL", 279 | "name": null, 280 | "ofType": { 281 | "kind": "SCALAR", 282 | "name": "Boolean", 283 | "ofType": null 284 | } 285 | }, 286 | "isDeprecated": false, 287 | "deprecationReason": null 288 | }, 289 | { 290 | "name": "startCursor", 291 | "description": "When paginating backwards, the cursor to continue.", 292 | "args": [], 293 | "type": { 294 | "kind": "SCALAR", 295 | "name": "String", 296 | "ofType": null 297 | }, 298 | "isDeprecated": false, 299 | "deprecationReason": null 300 | }, 301 | { 302 | "name": "endCursor", 303 | "description": "When paginating forwards, the cursor to continue.", 304 | "args": [], 305 | "type": { 306 | "kind": "SCALAR", 307 | "name": "String", 308 | "ofType": null 309 | }, 310 | "isDeprecated": false, 311 | "deprecationReason": null 312 | } 313 | ], 314 | "inputFields": null, 315 | "interfaces": [], 316 | "enumValues": null, 317 | "possibleTypes": null 318 | }, 319 | { 320 | "kind": "SCALAR", 321 | "name": "Boolean", 322 | "description": "The `Boolean` scalar type represents `true` or `false`.", 323 | "fields": null, 324 | "inputFields": null, 325 | "interfaces": null, 326 | "enumValues": null, 327 | "possibleTypes": null 328 | }, 329 | { 330 | "kind": "OBJECT", 331 | "name": "G0verEdge", 332 | "description": "An edge in a connection.", 333 | "fields": [ 334 | { 335 | "name": "node", 336 | "description": "The item at the end of the edge", 337 | "args": [], 338 | "type": { 339 | "kind": "OBJECT", 340 | "name": "G0ver", 341 | "ofType": null 342 | }, 343 | "isDeprecated": false, 344 | "deprecationReason": null 345 | }, 346 | { 347 | "name": "cursor", 348 | "description": "A cursor for use in pagination", 349 | "args": [], 350 | "type": { 351 | "kind": "NON_NULL", 352 | "name": null, 353 | "ofType": { 354 | "kind": "SCALAR", 355 | "name": "String", 356 | "ofType": null 357 | } 358 | }, 359 | "isDeprecated": false, 360 | "deprecationReason": null 361 | } 362 | ], 363 | "inputFields": null, 364 | "interfaces": [], 365 | "enumValues": null, 366 | "possibleTypes": null 367 | }, 368 | { 369 | "kind": "OBJECT", 370 | "name": "G0ver", 371 | "description": null, 372 | "fields": [ 373 | { 374 | "name": "id", 375 | "description": null, 376 | "args": [], 377 | "type": { 378 | "kind": "SCALAR", 379 | "name": "Primary", 380 | "ofType": null 381 | }, 382 | "isDeprecated": false, 383 | "deprecationReason": null 384 | }, 385 | { 386 | "name": "username", 387 | "description": null, 388 | "args": [], 389 | "type": { 390 | "kind": "SCALAR", 391 | "name": "String", 392 | "ofType": null 393 | }, 394 | "isDeprecated": false, 395 | "deprecationReason": null 396 | }, 397 | { 398 | "name": "skill", 399 | "description": null, 400 | "args": [], 401 | "type": { 402 | "kind": "LIST", 403 | "name": null, 404 | "ofType": { 405 | "kind": "SCALAR", 406 | "name": "String", 407 | "ofType": null 408 | } 409 | }, 410 | "isDeprecated": false, 411 | "deprecationReason": null 412 | }, 413 | { 414 | "name": "project", 415 | "description": null, 416 | "args": [ 417 | { 418 | "name": "id", 419 | "description": null, 420 | "type": { 421 | "kind": "SCALAR", 422 | "name": "Primary", 423 | "ofType": null 424 | }, 425 | "defaultValue": null 426 | }, 427 | { 428 | "name": "title", 429 | "description": null, 430 | "type": { 431 | "kind": "SCALAR", 432 | "name": "ID", 433 | "ofType": null 434 | }, 435 | "defaultValue": null 436 | } 437 | ], 438 | "type": { 439 | "kind": "LIST", 440 | "name": null, 441 | "ofType": { 442 | "kind": "OBJECT", 443 | "name": "Project", 444 | "ofType": null 445 | } 446 | }, 447 | "isDeprecated": false, 448 | "deprecationReason": null 449 | } 450 | ], 451 | "inputFields": null, 452 | "interfaces": [], 453 | "enumValues": null, 454 | "possibleTypes": null 455 | }, 456 | { 457 | "kind": "OBJECT", 458 | "name": "Project", 459 | "description": null, 460 | "fields": [ 461 | { 462 | "name": "id", 463 | "description": null, 464 | "args": [], 465 | "type": { 466 | "kind": "SCALAR", 467 | "name": "Primary", 468 | "ofType": null 469 | }, 470 | "isDeprecated": false, 471 | "deprecationReason": null 472 | }, 473 | { 474 | "name": "title", 475 | "description": null, 476 | "args": [], 477 | "type": { 478 | "kind": "SCALAR", 479 | "name": "String", 480 | "ofType": null 481 | }, 482 | "isDeprecated": false, 483 | "deprecationReason": null 484 | }, 485 | { 486 | "name": "description", 487 | "description": null, 488 | "args": [], 489 | "type": { 490 | "kind": "SCALAR", 491 | "name": "String", 492 | "ofType": null 493 | }, 494 | "isDeprecated": false, 495 | "deprecationReason": null 496 | }, 497 | { 498 | "name": "website", 499 | "description": null, 500 | "args": [], 501 | "type": { 502 | "kind": "SCALAR", 503 | "name": "String", 504 | "ofType": null 505 | }, 506 | "isDeprecated": false, 507 | "deprecationReason": null 508 | }, 509 | { 510 | "name": "github", 511 | "description": null, 512 | "args": [], 513 | "type": { 514 | "kind": "SCALAR", 515 | "name": "String", 516 | "ofType": null 517 | }, 518 | "isDeprecated": false, 519 | "deprecationReason": null 520 | }, 521 | { 522 | "name": "hackfoldr", 523 | "description": null, 524 | "args": [], 525 | "type": { 526 | "kind": "SCALAR", 527 | "name": "String", 528 | "ofType": null 529 | }, 530 | "isDeprecated": false, 531 | "deprecationReason": null 532 | }, 533 | { 534 | "name": "video", 535 | "description": null, 536 | "args": [], 537 | "type": { 538 | "kind": "SCALAR", 539 | "name": "String", 540 | "ofType": null 541 | }, 542 | "isDeprecated": false, 543 | "deprecationReason": null 544 | }, 545 | { 546 | "name": "g0ver", 547 | "description": null, 548 | "args": [ 549 | { 550 | "name": "id", 551 | "description": null, 552 | "type": { 553 | "kind": "SCALAR", 554 | "name": "Primary", 555 | "ofType": null 556 | }, 557 | "defaultValue": null 558 | }, 559 | { 560 | "name": "username", 561 | "description": null, 562 | "type": { 563 | "kind": "SCALAR", 564 | "name": "ID", 565 | "ofType": null 566 | }, 567 | "defaultValue": null 568 | } 569 | ], 570 | "type": { 571 | "kind": "LIST", 572 | "name": null, 573 | "ofType": { 574 | "kind": "OBJECT", 575 | "name": "G0ver", 576 | "ofType": null 577 | } 578 | }, 579 | "isDeprecated": false, 580 | "deprecationReason": null 581 | } 582 | ], 583 | "inputFields": null, 584 | "interfaces": [], 585 | "enumValues": null, 586 | "possibleTypes": null 587 | }, 588 | { 589 | "kind": "OBJECT", 590 | "name": "ProjectConnection", 591 | "description": "A connection to a list of items.", 592 | "fields": [ 593 | { 594 | "name": "pageInfo", 595 | "description": "Information to aid in pagination.", 596 | "args": [], 597 | "type": { 598 | "kind": "NON_NULL", 599 | "name": null, 600 | "ofType": { 601 | "kind": "OBJECT", 602 | "name": "PageInfo", 603 | "ofType": null 604 | } 605 | }, 606 | "isDeprecated": false, 607 | "deprecationReason": null 608 | }, 609 | { 610 | "name": "edges", 611 | "description": "A list of edges.", 612 | "args": [], 613 | "type": { 614 | "kind": "LIST", 615 | "name": null, 616 | "ofType": { 617 | "kind": "OBJECT", 618 | "name": "ProjectEdge", 619 | "ofType": null 620 | } 621 | }, 622 | "isDeprecated": false, 623 | "deprecationReason": null 624 | } 625 | ], 626 | "inputFields": null, 627 | "interfaces": [], 628 | "enumValues": null, 629 | "possibleTypes": null 630 | }, 631 | { 632 | "kind": "OBJECT", 633 | "name": "ProjectEdge", 634 | "description": "An edge in a connection.", 635 | "fields": [ 636 | { 637 | "name": "node", 638 | "description": "The item at the end of the edge", 639 | "args": [], 640 | "type": { 641 | "kind": "OBJECT", 642 | "name": "Project", 643 | "ofType": null 644 | }, 645 | "isDeprecated": false, 646 | "deprecationReason": null 647 | }, 648 | { 649 | "name": "cursor", 650 | "description": "A cursor for use in pagination", 651 | "args": [], 652 | "type": { 653 | "kind": "NON_NULL", 654 | "name": null, 655 | "ofType": { 656 | "kind": "SCALAR", 657 | "name": "String", 658 | "ofType": null 659 | } 660 | }, 661 | "isDeprecated": false, 662 | "deprecationReason": null 663 | } 664 | ], 665 | "inputFields": null, 666 | "interfaces": [], 667 | "enumValues": null, 668 | "possibleTypes": null 669 | }, 670 | { 671 | "kind": "OBJECT", 672 | "name": "Mutation", 673 | "description": null, 674 | "fields": [ 675 | { 676 | "name": "updateG0ver", 677 | "description": null, 678 | "args": [ 679 | { 680 | "name": "input", 681 | "description": null, 682 | "type": { 683 | "kind": "NON_NULL", 684 | "name": null, 685 | "ofType": { 686 | "kind": "INPUT_OBJECT", 687 | "name": "UpdateG0verInput", 688 | "ofType": null 689 | } 690 | }, 691 | "defaultValue": null 692 | } 693 | ], 694 | "type": { 695 | "kind": "OBJECT", 696 | "name": "UpdateG0verPayload", 697 | "ofType": null 698 | }, 699 | "isDeprecated": false, 700 | "deprecationReason": null 701 | } 702 | ], 703 | "inputFields": null, 704 | "interfaces": [], 705 | "enumValues": null, 706 | "possibleTypes": null 707 | }, 708 | { 709 | "kind": "INPUT_OBJECT", 710 | "name": "UpdateG0verInput", 711 | "description": null, 712 | "fields": null, 713 | "inputFields": [ 714 | { 715 | "name": "username", 716 | "description": null, 717 | "type": { 718 | "kind": "NON_NULL", 719 | "name": null, 720 | "ofType": { 721 | "kind": "SCALAR", 722 | "name": "String", 723 | "ofType": null 724 | } 725 | }, 726 | "defaultValue": null 727 | }, 728 | { 729 | "name": "skill", 730 | "description": null, 731 | "type": { 732 | "kind": "LIST", 733 | "name": null, 734 | "ofType": { 735 | "kind": "SCALAR", 736 | "name": "String", 737 | "ofType": null 738 | } 739 | }, 740 | "defaultValue": null 741 | }, 742 | { 743 | "name": "clientMutationId", 744 | "description": null, 745 | "type": { 746 | "kind": "SCALAR", 747 | "name": "String", 748 | "ofType": null 749 | }, 750 | "defaultValue": null 751 | } 752 | ], 753 | "interfaces": null, 754 | "enumValues": null, 755 | "possibleTypes": null 756 | }, 757 | { 758 | "kind": "OBJECT", 759 | "name": "UpdateG0verPayload", 760 | "description": null, 761 | "fields": [ 762 | { 763 | "name": "g0ver", 764 | "description": null, 765 | "args": [], 766 | "type": { 767 | "kind": "OBJECT", 768 | "name": "G0ver", 769 | "ofType": null 770 | }, 771 | "isDeprecated": false, 772 | "deprecationReason": null 773 | }, 774 | { 775 | "name": "clientMutationId", 776 | "description": null, 777 | "args": [], 778 | "type": { 779 | "kind": "SCALAR", 780 | "name": "String", 781 | "ofType": null 782 | }, 783 | "isDeprecated": false, 784 | "deprecationReason": null 785 | } 786 | ], 787 | "inputFields": null, 788 | "interfaces": [], 789 | "enumValues": null, 790 | "possibleTypes": null 791 | }, 792 | { 793 | "kind": "OBJECT", 794 | "name": "__Schema", 795 | "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", 796 | "fields": [ 797 | { 798 | "name": "types", 799 | "description": "A list of all types supported by this server.", 800 | "args": [], 801 | "type": { 802 | "kind": "NON_NULL", 803 | "name": null, 804 | "ofType": { 805 | "kind": "LIST", 806 | "name": null, 807 | "ofType": { 808 | "kind": "NON_NULL", 809 | "name": null, 810 | "ofType": { 811 | "kind": "OBJECT", 812 | "name": "__Type", 813 | "ofType": null 814 | } 815 | } 816 | } 817 | }, 818 | "isDeprecated": false, 819 | "deprecationReason": null 820 | }, 821 | { 822 | "name": "queryType", 823 | "description": "The type that query operations will be rooted at.", 824 | "args": [], 825 | "type": { 826 | "kind": "NON_NULL", 827 | "name": null, 828 | "ofType": { 829 | "kind": "OBJECT", 830 | "name": "__Type", 831 | "ofType": null 832 | } 833 | }, 834 | "isDeprecated": false, 835 | "deprecationReason": null 836 | }, 837 | { 838 | "name": "mutationType", 839 | "description": "If this server supports mutation, the type that mutation operations will be rooted at.", 840 | "args": [], 841 | "type": { 842 | "kind": "OBJECT", 843 | "name": "__Type", 844 | "ofType": null 845 | }, 846 | "isDeprecated": false, 847 | "deprecationReason": null 848 | }, 849 | { 850 | "name": "subscriptionType", 851 | "description": "If this server support subscription, the type that subscription operations will be rooted at.", 852 | "args": [], 853 | "type": { 854 | "kind": "OBJECT", 855 | "name": "__Type", 856 | "ofType": null 857 | }, 858 | "isDeprecated": false, 859 | "deprecationReason": null 860 | }, 861 | { 862 | "name": "directives", 863 | "description": "A list of all directives supported by this server.", 864 | "args": [], 865 | "type": { 866 | "kind": "NON_NULL", 867 | "name": null, 868 | "ofType": { 869 | "kind": "LIST", 870 | "name": null, 871 | "ofType": { 872 | "kind": "NON_NULL", 873 | "name": null, 874 | "ofType": { 875 | "kind": "OBJECT", 876 | "name": "__Directive", 877 | "ofType": null 878 | } 879 | } 880 | } 881 | }, 882 | "isDeprecated": false, 883 | "deprecationReason": null 884 | } 885 | ], 886 | "inputFields": null, 887 | "interfaces": [], 888 | "enumValues": null, 889 | "possibleTypes": null 890 | }, 891 | { 892 | "kind": "OBJECT", 893 | "name": "__Type", 894 | "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", 895 | "fields": [ 896 | { 897 | "name": "kind", 898 | "description": null, 899 | "args": [], 900 | "type": { 901 | "kind": "NON_NULL", 902 | "name": null, 903 | "ofType": { 904 | "kind": "ENUM", 905 | "name": "__TypeKind", 906 | "ofType": null 907 | } 908 | }, 909 | "isDeprecated": false, 910 | "deprecationReason": null 911 | }, 912 | { 913 | "name": "name", 914 | "description": null, 915 | "args": [], 916 | "type": { 917 | "kind": "SCALAR", 918 | "name": "String", 919 | "ofType": null 920 | }, 921 | "isDeprecated": false, 922 | "deprecationReason": null 923 | }, 924 | { 925 | "name": "description", 926 | "description": null, 927 | "args": [], 928 | "type": { 929 | "kind": "SCALAR", 930 | "name": "String", 931 | "ofType": null 932 | }, 933 | "isDeprecated": false, 934 | "deprecationReason": null 935 | }, 936 | { 937 | "name": "fields", 938 | "description": null, 939 | "args": [ 940 | { 941 | "name": "includeDeprecated", 942 | "description": null, 943 | "type": { 944 | "kind": "SCALAR", 945 | "name": "Boolean", 946 | "ofType": null 947 | }, 948 | "defaultValue": "false" 949 | } 950 | ], 951 | "type": { 952 | "kind": "LIST", 953 | "name": null, 954 | "ofType": { 955 | "kind": "NON_NULL", 956 | "name": null, 957 | "ofType": { 958 | "kind": "OBJECT", 959 | "name": "__Field", 960 | "ofType": null 961 | } 962 | } 963 | }, 964 | "isDeprecated": false, 965 | "deprecationReason": null 966 | }, 967 | { 968 | "name": "interfaces", 969 | "description": null, 970 | "args": [], 971 | "type": { 972 | "kind": "LIST", 973 | "name": null, 974 | "ofType": { 975 | "kind": "NON_NULL", 976 | "name": null, 977 | "ofType": { 978 | "kind": "OBJECT", 979 | "name": "__Type", 980 | "ofType": null 981 | } 982 | } 983 | }, 984 | "isDeprecated": false, 985 | "deprecationReason": null 986 | }, 987 | { 988 | "name": "possibleTypes", 989 | "description": null, 990 | "args": [], 991 | "type": { 992 | "kind": "LIST", 993 | "name": null, 994 | "ofType": { 995 | "kind": "NON_NULL", 996 | "name": null, 997 | "ofType": { 998 | "kind": "OBJECT", 999 | "name": "__Type", 1000 | "ofType": null 1001 | } 1002 | } 1003 | }, 1004 | "isDeprecated": false, 1005 | "deprecationReason": null 1006 | }, 1007 | { 1008 | "name": "enumValues", 1009 | "description": null, 1010 | "args": [ 1011 | { 1012 | "name": "includeDeprecated", 1013 | "description": null, 1014 | "type": { 1015 | "kind": "SCALAR", 1016 | "name": "Boolean", 1017 | "ofType": null 1018 | }, 1019 | "defaultValue": "false" 1020 | } 1021 | ], 1022 | "type": { 1023 | "kind": "LIST", 1024 | "name": null, 1025 | "ofType": { 1026 | "kind": "NON_NULL", 1027 | "name": null, 1028 | "ofType": { 1029 | "kind": "OBJECT", 1030 | "name": "__EnumValue", 1031 | "ofType": null 1032 | } 1033 | } 1034 | }, 1035 | "isDeprecated": false, 1036 | "deprecationReason": null 1037 | }, 1038 | { 1039 | "name": "inputFields", 1040 | "description": null, 1041 | "args": [], 1042 | "type": { 1043 | "kind": "LIST", 1044 | "name": null, 1045 | "ofType": { 1046 | "kind": "NON_NULL", 1047 | "name": null, 1048 | "ofType": { 1049 | "kind": "OBJECT", 1050 | "name": "__InputValue", 1051 | "ofType": null 1052 | } 1053 | } 1054 | }, 1055 | "isDeprecated": false, 1056 | "deprecationReason": null 1057 | }, 1058 | { 1059 | "name": "ofType", 1060 | "description": null, 1061 | "args": [], 1062 | "type": { 1063 | "kind": "OBJECT", 1064 | "name": "__Type", 1065 | "ofType": null 1066 | }, 1067 | "isDeprecated": false, 1068 | "deprecationReason": null 1069 | } 1070 | ], 1071 | "inputFields": null, 1072 | "interfaces": [], 1073 | "enumValues": null, 1074 | "possibleTypes": null 1075 | }, 1076 | { 1077 | "kind": "ENUM", 1078 | "name": "__TypeKind", 1079 | "description": "An enum describing what kind of type a given `__Type` is.", 1080 | "fields": null, 1081 | "inputFields": null, 1082 | "interfaces": null, 1083 | "enumValues": [ 1084 | { 1085 | "name": "SCALAR", 1086 | "description": "Indicates this type is a scalar.", 1087 | "isDeprecated": false, 1088 | "deprecationReason": null 1089 | }, 1090 | { 1091 | "name": "OBJECT", 1092 | "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", 1093 | "isDeprecated": false, 1094 | "deprecationReason": null 1095 | }, 1096 | { 1097 | "name": "INTERFACE", 1098 | "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", 1099 | "isDeprecated": false, 1100 | "deprecationReason": null 1101 | }, 1102 | { 1103 | "name": "UNION", 1104 | "description": "Indicates this type is a union. `possibleTypes` is a valid field.", 1105 | "isDeprecated": false, 1106 | "deprecationReason": null 1107 | }, 1108 | { 1109 | "name": "ENUM", 1110 | "description": "Indicates this type is an enum. `enumValues` is a valid field.", 1111 | "isDeprecated": false, 1112 | "deprecationReason": null 1113 | }, 1114 | { 1115 | "name": "INPUT_OBJECT", 1116 | "description": "Indicates this type is an input object. `inputFields` is a valid field.", 1117 | "isDeprecated": false, 1118 | "deprecationReason": null 1119 | }, 1120 | { 1121 | "name": "LIST", 1122 | "description": "Indicates this type is a list. `ofType` is a valid field.", 1123 | "isDeprecated": false, 1124 | "deprecationReason": null 1125 | }, 1126 | { 1127 | "name": "NON_NULL", 1128 | "description": "Indicates this type is a non-null. `ofType` is a valid field.", 1129 | "isDeprecated": false, 1130 | "deprecationReason": null 1131 | } 1132 | ], 1133 | "possibleTypes": null 1134 | }, 1135 | { 1136 | "kind": "OBJECT", 1137 | "name": "__Field", 1138 | "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", 1139 | "fields": [ 1140 | { 1141 | "name": "name", 1142 | "description": null, 1143 | "args": [], 1144 | "type": { 1145 | "kind": "NON_NULL", 1146 | "name": null, 1147 | "ofType": { 1148 | "kind": "SCALAR", 1149 | "name": "String", 1150 | "ofType": null 1151 | } 1152 | }, 1153 | "isDeprecated": false, 1154 | "deprecationReason": null 1155 | }, 1156 | { 1157 | "name": "description", 1158 | "description": null, 1159 | "args": [], 1160 | "type": { 1161 | "kind": "SCALAR", 1162 | "name": "String", 1163 | "ofType": null 1164 | }, 1165 | "isDeprecated": false, 1166 | "deprecationReason": null 1167 | }, 1168 | { 1169 | "name": "args", 1170 | "description": null, 1171 | "args": [], 1172 | "type": { 1173 | "kind": "NON_NULL", 1174 | "name": null, 1175 | "ofType": { 1176 | "kind": "LIST", 1177 | "name": null, 1178 | "ofType": { 1179 | "kind": "NON_NULL", 1180 | "name": null, 1181 | "ofType": { 1182 | "kind": "OBJECT", 1183 | "name": "__InputValue", 1184 | "ofType": null 1185 | } 1186 | } 1187 | } 1188 | }, 1189 | "isDeprecated": false, 1190 | "deprecationReason": null 1191 | }, 1192 | { 1193 | "name": "type", 1194 | "description": null, 1195 | "args": [], 1196 | "type": { 1197 | "kind": "NON_NULL", 1198 | "name": null, 1199 | "ofType": { 1200 | "kind": "OBJECT", 1201 | "name": "__Type", 1202 | "ofType": null 1203 | } 1204 | }, 1205 | "isDeprecated": false, 1206 | "deprecationReason": null 1207 | }, 1208 | { 1209 | "name": "isDeprecated", 1210 | "description": null, 1211 | "args": [], 1212 | "type": { 1213 | "kind": "NON_NULL", 1214 | "name": null, 1215 | "ofType": { 1216 | "kind": "SCALAR", 1217 | "name": "Boolean", 1218 | "ofType": null 1219 | } 1220 | }, 1221 | "isDeprecated": false, 1222 | "deprecationReason": null 1223 | }, 1224 | { 1225 | "name": "deprecationReason", 1226 | "description": null, 1227 | "args": [], 1228 | "type": { 1229 | "kind": "SCALAR", 1230 | "name": "String", 1231 | "ofType": null 1232 | }, 1233 | "isDeprecated": false, 1234 | "deprecationReason": null 1235 | } 1236 | ], 1237 | "inputFields": null, 1238 | "interfaces": [], 1239 | "enumValues": null, 1240 | "possibleTypes": null 1241 | }, 1242 | { 1243 | "kind": "OBJECT", 1244 | "name": "__InputValue", 1245 | "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", 1246 | "fields": [ 1247 | { 1248 | "name": "name", 1249 | "description": null, 1250 | "args": [], 1251 | "type": { 1252 | "kind": "NON_NULL", 1253 | "name": null, 1254 | "ofType": { 1255 | "kind": "SCALAR", 1256 | "name": "String", 1257 | "ofType": null 1258 | } 1259 | }, 1260 | "isDeprecated": false, 1261 | "deprecationReason": null 1262 | }, 1263 | { 1264 | "name": "description", 1265 | "description": null, 1266 | "args": [], 1267 | "type": { 1268 | "kind": "SCALAR", 1269 | "name": "String", 1270 | "ofType": null 1271 | }, 1272 | "isDeprecated": false, 1273 | "deprecationReason": null 1274 | }, 1275 | { 1276 | "name": "type", 1277 | "description": null, 1278 | "args": [], 1279 | "type": { 1280 | "kind": "NON_NULL", 1281 | "name": null, 1282 | "ofType": { 1283 | "kind": "OBJECT", 1284 | "name": "__Type", 1285 | "ofType": null 1286 | } 1287 | }, 1288 | "isDeprecated": false, 1289 | "deprecationReason": null 1290 | }, 1291 | { 1292 | "name": "defaultValue", 1293 | "description": "A GraphQL-formatted string representing the default value for this input value.", 1294 | "args": [], 1295 | "type": { 1296 | "kind": "SCALAR", 1297 | "name": "String", 1298 | "ofType": null 1299 | }, 1300 | "isDeprecated": false, 1301 | "deprecationReason": null 1302 | } 1303 | ], 1304 | "inputFields": null, 1305 | "interfaces": [], 1306 | "enumValues": null, 1307 | "possibleTypes": null 1308 | }, 1309 | { 1310 | "kind": "OBJECT", 1311 | "name": "__EnumValue", 1312 | "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", 1313 | "fields": [ 1314 | { 1315 | "name": "name", 1316 | "description": null, 1317 | "args": [], 1318 | "type": { 1319 | "kind": "NON_NULL", 1320 | "name": null, 1321 | "ofType": { 1322 | "kind": "SCALAR", 1323 | "name": "String", 1324 | "ofType": null 1325 | } 1326 | }, 1327 | "isDeprecated": false, 1328 | "deprecationReason": null 1329 | }, 1330 | { 1331 | "name": "description", 1332 | "description": null, 1333 | "args": [], 1334 | "type": { 1335 | "kind": "SCALAR", 1336 | "name": "String", 1337 | "ofType": null 1338 | }, 1339 | "isDeprecated": false, 1340 | "deprecationReason": null 1341 | }, 1342 | { 1343 | "name": "isDeprecated", 1344 | "description": null, 1345 | "args": [], 1346 | "type": { 1347 | "kind": "NON_NULL", 1348 | "name": null, 1349 | "ofType": { 1350 | "kind": "SCALAR", 1351 | "name": "Boolean", 1352 | "ofType": null 1353 | } 1354 | }, 1355 | "isDeprecated": false, 1356 | "deprecationReason": null 1357 | }, 1358 | { 1359 | "name": "deprecationReason", 1360 | "description": null, 1361 | "args": [], 1362 | "type": { 1363 | "kind": "SCALAR", 1364 | "name": "String", 1365 | "ofType": null 1366 | }, 1367 | "isDeprecated": false, 1368 | "deprecationReason": null 1369 | } 1370 | ], 1371 | "inputFields": null, 1372 | "interfaces": [], 1373 | "enumValues": null, 1374 | "possibleTypes": null 1375 | }, 1376 | { 1377 | "kind": "OBJECT", 1378 | "name": "__Directive", 1379 | "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", 1380 | "fields": [ 1381 | { 1382 | "name": "name", 1383 | "description": null, 1384 | "args": [], 1385 | "type": { 1386 | "kind": "NON_NULL", 1387 | "name": null, 1388 | "ofType": { 1389 | "kind": "SCALAR", 1390 | "name": "String", 1391 | "ofType": null 1392 | } 1393 | }, 1394 | "isDeprecated": false, 1395 | "deprecationReason": null 1396 | }, 1397 | { 1398 | "name": "description", 1399 | "description": null, 1400 | "args": [], 1401 | "type": { 1402 | "kind": "SCALAR", 1403 | "name": "String", 1404 | "ofType": null 1405 | }, 1406 | "isDeprecated": false, 1407 | "deprecationReason": null 1408 | }, 1409 | { 1410 | "name": "locations", 1411 | "description": null, 1412 | "args": [], 1413 | "type": { 1414 | "kind": "NON_NULL", 1415 | "name": null, 1416 | "ofType": { 1417 | "kind": "LIST", 1418 | "name": null, 1419 | "ofType": { 1420 | "kind": "NON_NULL", 1421 | "name": null, 1422 | "ofType": { 1423 | "kind": "ENUM", 1424 | "name": "__DirectiveLocation", 1425 | "ofType": null 1426 | } 1427 | } 1428 | } 1429 | }, 1430 | "isDeprecated": false, 1431 | "deprecationReason": null 1432 | }, 1433 | { 1434 | "name": "args", 1435 | "description": null, 1436 | "args": [], 1437 | "type": { 1438 | "kind": "NON_NULL", 1439 | "name": null, 1440 | "ofType": { 1441 | "kind": "LIST", 1442 | "name": null, 1443 | "ofType": { 1444 | "kind": "NON_NULL", 1445 | "name": null, 1446 | "ofType": { 1447 | "kind": "OBJECT", 1448 | "name": "__InputValue", 1449 | "ofType": null 1450 | } 1451 | } 1452 | } 1453 | }, 1454 | "isDeprecated": false, 1455 | "deprecationReason": null 1456 | }, 1457 | { 1458 | "name": "onOperation", 1459 | "description": null, 1460 | "args": [], 1461 | "type": { 1462 | "kind": "NON_NULL", 1463 | "name": null, 1464 | "ofType": { 1465 | "kind": "SCALAR", 1466 | "name": "Boolean", 1467 | "ofType": null 1468 | } 1469 | }, 1470 | "isDeprecated": true, 1471 | "deprecationReason": "Use `locations`." 1472 | }, 1473 | { 1474 | "name": "onFragment", 1475 | "description": null, 1476 | "args": [], 1477 | "type": { 1478 | "kind": "NON_NULL", 1479 | "name": null, 1480 | "ofType": { 1481 | "kind": "SCALAR", 1482 | "name": "Boolean", 1483 | "ofType": null 1484 | } 1485 | }, 1486 | "isDeprecated": true, 1487 | "deprecationReason": "Use `locations`." 1488 | }, 1489 | { 1490 | "name": "onField", 1491 | "description": null, 1492 | "args": [], 1493 | "type": { 1494 | "kind": "NON_NULL", 1495 | "name": null, 1496 | "ofType": { 1497 | "kind": "SCALAR", 1498 | "name": "Boolean", 1499 | "ofType": null 1500 | } 1501 | }, 1502 | "isDeprecated": true, 1503 | "deprecationReason": "Use `locations`." 1504 | } 1505 | ], 1506 | "inputFields": null, 1507 | "interfaces": [], 1508 | "enumValues": null, 1509 | "possibleTypes": null 1510 | }, 1511 | { 1512 | "kind": "ENUM", 1513 | "name": "__DirectiveLocation", 1514 | "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", 1515 | "fields": null, 1516 | "inputFields": null, 1517 | "interfaces": null, 1518 | "enumValues": [ 1519 | { 1520 | "name": "QUERY", 1521 | "description": "Location adjacent to a query operation.", 1522 | "isDeprecated": false, 1523 | "deprecationReason": null 1524 | }, 1525 | { 1526 | "name": "MUTATION", 1527 | "description": "Location adjacent to a mutation operation.", 1528 | "isDeprecated": false, 1529 | "deprecationReason": null 1530 | }, 1531 | { 1532 | "name": "SUBSCRIPTION", 1533 | "description": "Location adjacent to a subscription operation.", 1534 | "isDeprecated": false, 1535 | "deprecationReason": null 1536 | }, 1537 | { 1538 | "name": "FIELD", 1539 | "description": "Location adjacent to a field.", 1540 | "isDeprecated": false, 1541 | "deprecationReason": null 1542 | }, 1543 | { 1544 | "name": "FRAGMENT_DEFINITION", 1545 | "description": "Location adjacent to a fragment definition.", 1546 | "isDeprecated": false, 1547 | "deprecationReason": null 1548 | }, 1549 | { 1550 | "name": "FRAGMENT_SPREAD", 1551 | "description": "Location adjacent to a fragment spread.", 1552 | "isDeprecated": false, 1553 | "deprecationReason": null 1554 | }, 1555 | { 1556 | "name": "INLINE_FRAGMENT", 1557 | "description": "Location adjacent to an inline fragment.", 1558 | "isDeprecated": false, 1559 | "deprecationReason": null 1560 | }, 1561 | { 1562 | "name": "SCHEMA", 1563 | "description": "Location adjacent to a schema definition.", 1564 | "isDeprecated": false, 1565 | "deprecationReason": null 1566 | }, 1567 | { 1568 | "name": "SCALAR", 1569 | "description": "Location adjacent to a scalar definition.", 1570 | "isDeprecated": false, 1571 | "deprecationReason": null 1572 | }, 1573 | { 1574 | "name": "OBJECT", 1575 | "description": "Location adjacent to an object type definition.", 1576 | "isDeprecated": false, 1577 | "deprecationReason": null 1578 | }, 1579 | { 1580 | "name": "FIELD_DEFINITION", 1581 | "description": "Location adjacent to a field definition.", 1582 | "isDeprecated": false, 1583 | "deprecationReason": null 1584 | }, 1585 | { 1586 | "name": "ARGUMENT_DEFINITION", 1587 | "description": "Location adjacent to an argument definition.", 1588 | "isDeprecated": false, 1589 | "deprecationReason": null 1590 | }, 1591 | { 1592 | "name": "INTERFACE", 1593 | "description": "Location adjacent to an interface definition.", 1594 | "isDeprecated": false, 1595 | "deprecationReason": null 1596 | }, 1597 | { 1598 | "name": "UNION", 1599 | "description": "Location adjacent to a union definition.", 1600 | "isDeprecated": false, 1601 | "deprecationReason": null 1602 | }, 1603 | { 1604 | "name": "ENUM", 1605 | "description": "Location adjacent to an enum definition.", 1606 | "isDeprecated": false, 1607 | "deprecationReason": null 1608 | }, 1609 | { 1610 | "name": "ENUM_VALUE", 1611 | "description": "Location adjacent to an enum value definition.", 1612 | "isDeprecated": false, 1613 | "deprecationReason": null 1614 | }, 1615 | { 1616 | "name": "INPUT_OBJECT", 1617 | "description": "Location adjacent to an input object type definition.", 1618 | "isDeprecated": false, 1619 | "deprecationReason": null 1620 | }, 1621 | { 1622 | "name": "INPUT_FIELD_DEFINITION", 1623 | "description": "Location adjacent to an input object field definition.", 1624 | "isDeprecated": false, 1625 | "deprecationReason": null 1626 | } 1627 | ], 1628 | "possibleTypes": null 1629 | } 1630 | ], 1631 | "directives": [ 1632 | { 1633 | "name": "include", 1634 | "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", 1635 | "locations": [ 1636 | "FIELD", 1637 | "FRAGMENT_SPREAD", 1638 | "INLINE_FRAGMENT" 1639 | ], 1640 | "args": [ 1641 | { 1642 | "name": "if", 1643 | "description": "Included when true.", 1644 | "type": { 1645 | "kind": "NON_NULL", 1646 | "name": null, 1647 | "ofType": { 1648 | "kind": "SCALAR", 1649 | "name": "Boolean", 1650 | "ofType": null 1651 | } 1652 | }, 1653 | "defaultValue": null 1654 | } 1655 | ] 1656 | }, 1657 | { 1658 | "name": "skip", 1659 | "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", 1660 | "locations": [ 1661 | "FIELD", 1662 | "FRAGMENT_SPREAD", 1663 | "INLINE_FRAGMENT" 1664 | ], 1665 | "args": [ 1666 | { 1667 | "name": "if", 1668 | "description": "Skipped when true.", 1669 | "type": { 1670 | "kind": "NON_NULL", 1671 | "name": null, 1672 | "ofType": { 1673 | "kind": "SCALAR", 1674 | "name": "Boolean", 1675 | "ofType": null 1676 | } 1677 | }, 1678 | "defaultValue": null 1679 | } 1680 | ] 1681 | }, 1682 | { 1683 | "name": "deprecated", 1684 | "description": "Marks an element of a GraphQL schema as no longer supported.", 1685 | "locations": [ 1686 | "FIELD_DEFINITION", 1687 | "ENUM_VALUE" 1688 | ], 1689 | "args": [ 1690 | { 1691 | "name": "reason", 1692 | "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).", 1693 | "type": { 1694 | "kind": "SCALAR", 1695 | "name": "String", 1696 | "ofType": null 1697 | }, 1698 | "defaultValue": "\"No longer supported\"" 1699 | } 1700 | ] 1701 | } 1702 | ] 1703 | } 1704 | } 1705 | } --------------------------------------------------------------------------------