├── webroot ├── .editorconfig ├── .gitignore ├── .eslintrc ├── src │ ├── index.html │ ├── index.jsx │ ├── models │ │ ├── RouteModel.js │ │ └── MessageModel.js │ ├── styles │ │ └── index.css │ ├── components │ │ ├── Home.jsx │ │ ├── Console.jsx │ │ ├── sms │ │ │ ├── DetailsPage.jsx │ │ │ ├── SMSList.jsx │ │ │ ├── SendPage.jsx │ │ │ └── SMSPage.jsx │ │ └── router │ │ │ ├── RouteDialog.jsx │ │ │ └── RouterPage.jsx │ ├── stores │ │ ├── RouteStore.js │ │ ├── MessageStore.js │ │ └── API.js │ └── App.jsx ├── webpack-dev-server.js ├── .babelrc ├── Makefile ├── package.json └── webpack.config.js ├── docs └── screenshot │ ├── logs.jpg │ └── router.jpg ├── smsender ├── store │ ├── sql │ │ ├── utils.go │ │ ├── store.go │ │ ├── route_store.go │ │ └── message_store.go │ ├── dummy │ │ ├── store.go │ │ └── route_store.go │ ├── store.go │ └── memory │ │ ├── store.go │ │ ├── route_store.go │ │ └── message_store.go ├── model │ ├── webhook.go │ ├── utils.go │ ├── provider.go │ ├── stats.go │ ├── route.go │ ├── json.go │ ├── message_test.go │ ├── status_code.go │ └── message.go ├── utils │ ├── middleware.go │ ├── json.go │ ├── utils.go │ └── validate.go ├── cmd │ ├── routes.go │ ├── init.go │ ├── smsender.go │ └── send.go ├── providers │ ├── notfound │ │ └── provider.go │ ├── dummy │ │ └── provider.go │ ├── aws │ │ └── provider.go │ ├── twilio │ │ └── provider.go │ └── nexmo │ │ └── provider.go ├── plugin │ └── plugin.go ├── api │ ├── utils.go │ ├── api.go │ └── handlers.go ├── web │ └── web.go ├── worker.go ├── smsender.go └── router │ ├── router.go │ └── router_test.go ├── scripts └── docker_push.sh ├── Dockerfile ├── .editorconfig ├── .gitignore ├── docker-compose-dev.yml ├── cmd └── smsender │ └── main.go ├── config ├── config.default.yml └── config.default.json ├── docker-compose.yml ├── .travis.yml ├── Makefile ├── docker-entrypoint.sh ├── LICENSE.md ├── go.mod ├── provision.sh ├── Vagrantfile ├── README.md ├── go.sum └── openapi.yaml /webroot/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 -------------------------------------------------------------------------------- /webroot/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /node_modules/ 3 | -------------------------------------------------------------------------------- /docs/screenshot/logs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minchao/smsender/HEAD/docs/screenshot/logs.jpg -------------------------------------------------------------------------------- /docs/screenshot/router.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minchao/smsender/HEAD/docs/screenshot/router.jpg -------------------------------------------------------------------------------- /smsender/store/sql/utils.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | func sqlAndWhere(where string) string { 4 | if where == "" { 5 | return "" 6 | } 7 | return " AND" 8 | } 9 | -------------------------------------------------------------------------------- /smsender/model/webhook.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "net/http" 4 | 5 | // Webhook represents a webhook endpoint. 6 | type Webhook struct { 7 | Path string 8 | Func func(http.ResponseWriter, *http.Request) 9 | Method string 10 | } 11 | -------------------------------------------------------------------------------- /scripts/docker_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker build -t minchao/smsender-preview:latest . 4 | echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin 5 | docker images 6 | docker push "${DOCKER_USERNAME}/smsender-preview" 7 | docker logout 8 | -------------------------------------------------------------------------------- /smsender/model/utils.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Now return the current time.Time with microsecond precision. 8 | func Now() time.Time { 9 | now := time.Now() 10 | return time.Unix(now.Unix(), int64(now.Nanosecond())/1000*1000) 11 | } 12 | -------------------------------------------------------------------------------- /smsender/model/provider.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | const NotFoundProvider = "_not_found_" 4 | 5 | type Provider interface { 6 | Name() string 7 | Send(message Message) *MessageResponse 8 | Callback(register func(webhook *Webhook), receipts chan<- MessageReceipt) 9 | } 10 | 11 | type ProviderError struct { 12 | Error string `json:"error"` 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | RUN apt-get update && apt-get -y install netcat 4 | RUN mkdir -p /smsender/config 5 | 6 | COPY bin/smsender /smsender/ 7 | COPY config/config.default.yml / 8 | COPY webroot/dist /smsender/webroot/dist/ 9 | 10 | COPY docker-entrypoint.sh / 11 | RUN chmod +x /docker-entrypoint.sh 12 | ENTRYPOINT ["/docker-entrypoint.sh"] 13 | 14 | EXPOSE 8080 -------------------------------------------------------------------------------- /smsender/store/dummy/store.go: -------------------------------------------------------------------------------- 1 | package dummy 2 | 3 | import "github.com/minchao/smsender/smsender/store" 4 | 5 | type Store struct { 6 | DummyRoute store.RouteStore 7 | DummyMessage store.MessageStore 8 | } 9 | 10 | func (s *Store) Route() store.RouteStore { 11 | return s.DummyRoute 12 | } 13 | 14 | func (s *Store) Message() store.MessageStore { 15 | return s.DummyMessage 16 | } 17 | -------------------------------------------------------------------------------- /smsender/store/dummy/route_store.go: -------------------------------------------------------------------------------- 1 | package dummy 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/model" 5 | "github.com/minchao/smsender/smsender/store" 6 | ) 7 | 8 | type RouteStore struct { 9 | } 10 | 11 | func (rs *RouteStore) GetAll() store.Channel { 12 | return make(store.Channel, 1) 13 | } 14 | 15 | func (rs *RouteStore) SaveAll(routes []*model.Route) store.Channel { 16 | return make(store.Channel, 1) 17 | } 18 | -------------------------------------------------------------------------------- /webroot/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "standard-jsx"], 3 | "env": { 4 | "browser": true 5 | }, 6 | "parser": "babel-eslint", 7 | "rules": { 8 | "jsx-quotes": [2, "prefer-double"], 9 | "lines-between-class-members": [1], 10 | "object-curly-spacing": [1], 11 | "quote-props": [1], 12 | "no-prototype-builtins": [1] 13 | }, 14 | "globals": { 15 | "__DEVELOPMENT__": true, 16 | "API_HOST": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.go] 13 | indent_style = tab 14 | 15 | [*.sh] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.{json,toml,yaml,yml}] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [Makefile] 24 | indent_style = tab 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Vendor (dependencies) directory 12 | /vendor/ 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Output binary directory 18 | /bin/ 19 | 20 | # IDE 21 | /.idea/ 22 | 23 | # Virtual machine 24 | /.vagrant/ 25 | 26 | /config.json 27 | /config.yml 28 | 29 | -------------------------------------------------------------------------------- /webroot/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SMSender 7 | 11 | 12 | 13 | 14 |
15 | 16 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | 5 | db: 6 | image: mysql:5.7.19 7 | container_name: smsender_db 8 | restart: always 9 | ports: 10 | - "3306:3306" 11 | environment: 12 | - MYSQL_ROOT_PASSWORD=root_password 13 | - MYSQL_DATABASE=smsender 14 | - MYSQL_USER=smsender_user 15 | - MYSQL_PASSWORD=smsender_password 16 | 17 | adminer: 18 | image: adminer 19 | container_name: smsender_adminer 20 | restart: always 21 | ports: 22 | - "8081:8080" -------------------------------------------------------------------------------- /smsender/utils/middleware.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Logger middleware handles the HTTP log. 11 | func Logger(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 12 | start := time.Now() 13 | path := r.URL.Path 14 | 15 | next(w, r) 16 | 17 | end := time.Now() 18 | 19 | log.Printf( 20 | "%s %s %s %13v", 21 | end.Format("2006/01/02 - 15:04:05"), 22 | r.Method, 23 | path, 24 | end.Sub(start), 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /smsender/utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | 8 | "gopkg.in/go-playground/validator.v9" 9 | ) 10 | 11 | func GetInput(body io.Reader, to interface{}, v *validator.Validate) error { 12 | data, err := ioutil.ReadAll(body) 13 | if err != nil { 14 | return err 15 | } 16 | err = json.Unmarshal(data, to) 17 | if err != nil { 18 | return err 19 | } 20 | if v != nil { 21 | if err = v.Struct(to); err != nil { 22 | return err 23 | } 24 | } 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /webroot/webpack-dev-server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const WebpackDevServer = require('webpack-dev-server') 3 | 4 | const config = require('./webpack.config') 5 | 6 | new WebpackDevServer(webpack(config()), { 7 | publicPath: config().output.publicPath, 8 | hot: true, 9 | historyApiFallback: { 10 | index: '/dist/' 11 | }, 12 | stats: { colors: true } 13 | }).listen(3000, 'localhost', function (err) { 14 | if (err) { 15 | console.log(err) 16 | } 17 | console.log('Listening at localhost:3000') 18 | }) 19 | -------------------------------------------------------------------------------- /webroot/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "production": { 4 | "presets": [ 5 | "react", 6 | ["es2015", {"modules": false}], 7 | "stage-1" 8 | ], 9 | "plugins": [ 10 | "transform-decorators-legacy" 11 | ] 12 | }, 13 | "development": { 14 | "presets": [ 15 | "react", 16 | ["es2015", {"modules": false}], 17 | "stage-1" 18 | ], 19 | "plugins": [ 20 | "transform-decorators-legacy", 21 | "react-hot-loader/babel" 22 | ] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cmd/smsender/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/cmd" 5 | 6 | // Register builtin stores. 7 | _ "github.com/minchao/smsender/smsender/store/memory" 8 | _ "github.com/minchao/smsender/smsender/store/sql" 9 | 10 | // Register builtin providers. 11 | _ "github.com/minchao/smsender/smsender/providers/aws" 12 | _ "github.com/minchao/smsender/smsender/providers/dummy" 13 | _ "github.com/minchao/smsender/smsender/providers/nexmo" 14 | _ "github.com/minchao/smsender/smsender/providers/twilio" 15 | ) 16 | 17 | func main() { 18 | cmd.Execute() 19 | } 20 | -------------------------------------------------------------------------------- /smsender/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // UnixMicroStringToTime returns the time.Time corresponding to the given Unix micro time string. 9 | func UnixMicroStringToTime(s string) (time.Time, error) { 10 | validate := NewValidate() 11 | _ = validate.RegisterValidation("unixmicro", IsTimeUnixMicro) 12 | if err := validate.Var(s, "unixmicro"); err != nil { 13 | return time.Time{}, err 14 | } 15 | 16 | sec, _ := strconv.ParseInt(s[:10], 10, 64) 17 | nsec, _ := strconv.ParseInt(s[10:], 10, 64) 18 | 19 | return time.Unix(sec, nsec*1000), nil 20 | } 21 | -------------------------------------------------------------------------------- /webroot/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: yarn-install lint dev build build-with-docker clean 2 | 3 | yarn-install: 4 | @echo Getting dependencies using yarn 5 | yarn install 6 | 7 | lint: 8 | @echo Checking lint 9 | yarn run lint 10 | 11 | dev: yarn-install 12 | @echo Running webapp for development 13 | yarn run dev 14 | 15 | build: yarn-install 16 | @echo Building webapp 17 | yarn run build 18 | 19 | build-with-docker: clean 20 | @echo Building webapp with Docker 21 | docker run --rm -v $(PWD):/smsender -w /smsender node:10.15.0 sh -c "make build" 22 | 23 | clean: 24 | @echo Cleaning webapp 25 | rm -rf ./dist ./node_modules -------------------------------------------------------------------------------- /webroot/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { AppContainer } from 'react-hot-loader' 4 | import injectTapEventPlugin from 'react-tap-event-plugin' 5 | 6 | import App from './App' 7 | 8 | import './styles/index.css' 9 | 10 | injectTapEventPlugin() 11 | 12 | const render = Component => { 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | document.getElementById('root') 18 | ) 19 | } 20 | 21 | render(App) 22 | 23 | if (__DEVELOPMENT__ && module.hot) { 24 | module.hot.accept('./App', () => { 25 | render(App) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /smsender/cmd/routes.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/minchao/smsender/smsender" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var routesCmd = &cobra.Command{ 12 | Use: "routes", 13 | Short: "List all routes", 14 | Example: ` routes`, 15 | RunE: routesCmdF, 16 | } 17 | 18 | func routesCmdF(cmd *cobra.Command, args []string) error { 19 | if err := initEnv(cmd); err != nil { 20 | return err 21 | } 22 | 23 | sender := smsender.NewSender() 24 | resultJSON, _ := json.MarshalIndent(sender.Router.GetAll(), "", " ") 25 | 26 | log.Infof("Routes:\n%s", resultJSON) 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /smsender/providers/notfound/provider.go: -------------------------------------------------------------------------------- 1 | package notfound 2 | 3 | import "github.com/minchao/smsender/smsender/model" 4 | 5 | type Provider struct { 6 | name string 7 | } 8 | 9 | func New(name string) *Provider { 10 | return &Provider{ 11 | name: name, 12 | } 13 | } 14 | 15 | func (b Provider) Name() string { 16 | return b.name 17 | } 18 | 19 | func (b Provider) Send(message model.Message) *model.MessageResponse { 20 | return model.NewMessageResponse(model.StatusFailed, 21 | struct { 22 | Error string `json:"error"` 23 | }{ 24 | Error: "no_route_matches", 25 | }, 26 | &message.ID) 27 | } 28 | 29 | func (b Provider) Callback(register func(webhook *model.Webhook), receiptsCh chan<- model.MessageReceipt) { 30 | } 31 | -------------------------------------------------------------------------------- /smsender/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/model" 5 | ) 6 | 7 | type Result struct { 8 | Data interface{} 9 | Err error 10 | } 11 | 12 | type Channel chan Result 13 | 14 | type Store interface { 15 | Route() RouteStore 16 | Message() MessageStore 17 | } 18 | 19 | type RouteStore interface { 20 | GetAll() Channel 21 | SaveAll(routes []*model.Route) Channel 22 | } 23 | 24 | type MessageStore interface { 25 | Get(id string) Channel 26 | GetByIds(ids []string) Channel 27 | GetByProviderAndMessageID(provider, providerMessageID string) Channel 28 | Save(message *model.Message) Channel 29 | Search(params map[string]interface{}) Channel 30 | Update(message *model.Message) Channel 31 | } 32 | -------------------------------------------------------------------------------- /smsender/model/stats.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "runtime" 5 | "time" 6 | ) 7 | 8 | type Stats struct { 9 | Time int64 `json:"time"` 10 | GoVersion string `json:"go_version"` 11 | GoMaxProcs int `json:"go_max_procs"` 12 | NumCPU int `json:"num_cpu"` 13 | NumGoroutine int `json:"num_goroutine"` 14 | MemSys uint64 `json:"mem_sys"` 15 | } 16 | 17 | func NewStats() *Stats { 18 | var mem runtime.MemStats 19 | runtime.ReadMemStats(&mem) 20 | 21 | return &Stats{ 22 | Time: time.Now().UnixNano(), 23 | GoVersion: runtime.Version(), 24 | GoMaxProcs: runtime.GOMAXPROCS(0), 25 | NumCPU: runtime.NumCPU(), 26 | NumGoroutine: runtime.NumGoroutine(), 27 | MemSys: mem.Sys, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /smsender/store/memory/store.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/plugin" 5 | "github.com/minchao/smsender/smsender/store" 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | func init() { 10 | plugin.RegisterStore("memory", Plugin) 11 | } 12 | 13 | func Plugin(config *viper.Viper) (store.Store, error) { 14 | return New(), nil 15 | } 16 | 17 | type Store struct { 18 | route store.RouteStore 19 | message store.MessageStore 20 | } 21 | 22 | func New() store.Store { 23 | memoryStore := &Store{} 24 | memoryStore.route = NewMemoryRouteStore(memoryStore) 25 | memoryStore.message = NewMemoryMessageStore(memoryStore) 26 | 27 | return memoryStore 28 | } 29 | 30 | func (s *Store) Route() store.RouteStore { 31 | return s.route 32 | } 33 | 34 | func (s *Store) Message() store.MessageStore { 35 | return s.message 36 | } 37 | -------------------------------------------------------------------------------- /smsender/cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | config "github.com/spf13/viper" 9 | ) 10 | 11 | func initEnv(cmd *cobra.Command) error { 12 | configFile, err := cmd.Flags().GetString("config") 13 | if err != nil { 14 | return err 15 | } 16 | 17 | if len(configFile) > 0 { 18 | config.SetConfigFile(configFile) 19 | } else { 20 | config.SetConfigName("config") 21 | config.AddConfigPath(".") 22 | } 23 | if err := config.ReadInConfig(); err != nil { 24 | return fmt.Errorf("Unable to read config file %v", err) 25 | } 26 | 27 | log.Infof("Config path: %s", config.ConfigFileUsed()) 28 | 29 | if debug, _ := cmd.Flags().GetBool("debug"); debug { 30 | log.SetLevel(log.DebugLevel) 31 | log.Debugln("Running in debug mode") 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /webroot/src/models/RouteModel.js: -------------------------------------------------------------------------------- 1 | import { action, observable } from 'mobx' 2 | 3 | export default class RouteModel { 4 | @observable name 5 | @observable pattern 6 | @observable provider 7 | @observable from 8 | @observable is_active // eslint-disable-line 9 | 10 | @action set (key, value) { 11 | this[key] = value 12 | } 13 | 14 | @action fromJS (object) { 15 | this.name = object.name 16 | this.pattern = object.pattern 17 | this.provider = object.provider 18 | this.from = object.from 19 | this.is_active = object.is_active 20 | 21 | return this 22 | } 23 | 24 | toJS () { 25 | return { 26 | name: this.name, 27 | pattern: this.pattern, 28 | provider: this.provider, 29 | from: this.from, 30 | is_active: this.is_active 31 | } 32 | } 33 | 34 | static new (object) { 35 | return (new RouteModel().fromJS(object)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/config.default.yml: -------------------------------------------------------------------------------- 1 | store: 2 | name: "sql" 3 | sql: 4 | driver: "mysql" 5 | dsn: "MYSQL_USER:MYSQL_PASSWORD@tcp(MYSQL_HOST:MYSQL_PORT)/MYSQL_DATABASE?parseTime=true&loc=Local" 6 | connection: 7 | maxOpenConns: 100 8 | maxIdleConns: 50 9 | http: 10 | enable: true 11 | siteURL: "http://127.0.0.1:8080" 12 | addr: ":8080" 13 | tls: false 14 | tlsCertFile: "" 15 | tlsKeyFile: "" 16 | api: 17 | cors: 18 | enable: false 19 | origins: 20 | - # "http://127.0.0.1:8080" 21 | headers: 22 | - "*" 23 | methods: 24 | - "GET" 25 | - "POST" 26 | - "PUT" 27 | - "PATCH" 28 | - "DELETE" 29 | debug: false 30 | web: 31 | enable: true 32 | worker: 33 | num: 100 34 | providers: 35 | dummy: 36 | # nexmo: 37 | # key: "NEXMO_KEY" 38 | # secret: "NEXMO_SECRET" 39 | # webhook: 40 | # enable: true -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | 5 | db: 6 | image: mysql:5.7.19 7 | container_name: smsender_db 8 | restart: always 9 | environment: 10 | - MYSQL_ROOT_PASSWORD=root_password 11 | - MYSQL_DATABASE=smsender 12 | - MYSQL_USER=smsender_user 13 | - MYSQL_PASSWORD=smsender_password 14 | 15 | app: 16 | depends_on: 17 | - db 18 | image: minchao/smsender-preview 19 | container_name: smsender_app 20 | ports: 21 | - "8080:8080" 22 | restart: always 23 | volumes: 24 | - ./config:/smsender/config:rw 25 | environment: 26 | - MYSQL_DATABASE=smsender 27 | - MYSQL_USER=smsender_user 28 | - MYSQL_PASSWORD=smsender_password 29 | - MYSQL_HOST=db 30 | - MYSQL_PORT=3306 31 | 32 | adminer: 33 | image: adminer 34 | container_name: smsender_adminer 35 | restart: always 36 | ports: 37 | - "8081:8080" -------------------------------------------------------------------------------- /smsender/providers/dummy/provider.go: -------------------------------------------------------------------------------- 1 | package dummy 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/model" 5 | "github.com/minchao/smsender/smsender/plugin" 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | const name = "dummy" 10 | 11 | func init() { 12 | plugin.RegisterProvider(name, Plugin) 13 | } 14 | 15 | func Plugin(config *viper.Viper) (model.Provider, error) { 16 | return New(name), nil 17 | } 18 | 19 | type Provider struct { 20 | name string 21 | } 22 | 23 | // New creates Dummy Provider. 24 | func New(name string) *Provider { 25 | return &Provider{ 26 | name: name, 27 | } 28 | } 29 | 30 | func (b Provider) Name() string { 31 | return b.name 32 | } 33 | 34 | func (b Provider) Send(message model.Message) *model.MessageResponse { 35 | return model.NewMessageResponse(model.StatusDelivered, nil, &message.ID) 36 | } 37 | 38 | func (b Provider) Callback(register func(webhook *model.Webhook), receiptsCh chan<- model.MessageReceipt) { 39 | } 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | dist: xenial 3 | 4 | go: 5 | - "1.11.x" 6 | - "1.12.x" 7 | - master 8 | 9 | matrix: 10 | allow_failures: 11 | - go: master 12 | 13 | services: 14 | - docker 15 | 16 | cache: 17 | directories: 18 | - $GOPATH/pkg/mod 19 | - webroot/node_modules 20 | 21 | env: 22 | global: 23 | - GO111MODULE=on 24 | 25 | before_install: 26 | - nvm install 10.15.0 27 | - nvm use 10.15.0 28 | - npm install -g yarn 29 | 30 | install: 31 | - curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin latest 32 | - go mod download 33 | - cd webroot && make yarn-install && cd .. 34 | 35 | script: 36 | - make lint 37 | - make test 38 | - make build 39 | - cd webroot 40 | - make lint 41 | - make build 42 | - cd .. 43 | 44 | deploy: 45 | provider: script 46 | script: bash scripts/docker_push.sh 47 | skip_cleanup: true 48 | on: 49 | branch: master 50 | go: "1.12.x" 51 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check-style test build build-with-docker docker-build 2 | 3 | BUILD_EXECUTABLE := smsender 4 | PACKAGES := $(shell go list ./... | grep -v /vendor/) 5 | 6 | export GO111MODULE=on 7 | 8 | all: build 9 | 10 | lint: 11 | @echo Running lint 12 | @golangci-lint run -E gofmt ./smsender/... 13 | 14 | test: 15 | @echo Testing 16 | @go test -race -v $(PACKAGES) 17 | 18 | build: 19 | @echo Building app 20 | go build -o ./bin/$(BUILD_EXECUTABLE) ./cmd/smsender/main.go 21 | 22 | clean: 23 | @echo Cleaning up previous build data 24 | rm -f ./bin/$(BUILD_EXECUTABLE) 25 | rm -rf ./vendor 26 | 27 | build-with-docker: clean 28 | @echo Building app with Docker 29 | docker run --rm -v $(PWD):/go/src/github.com/minchao/smsender -w /go/src/github.com/minchao/smsender -e GO111MODULE=on golang sh -c "make build" 30 | 31 | cd webroot && make build-with-docker 32 | 33 | docker-build: build-with-docker 34 | @echo Building Docker image 35 | docker build -t minchao/smsender-preview:latest . 36 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | config=/smsender/config/config.yml 8 | 9 | MYSQL_USER=${MYSQL_USER:-smsender_user} 10 | MYSQL_PASSWORD=${MYSQL_PASSWORD:-smsender_password} 11 | MYSQL_HOST=${MYSQL_HOST:-db} 12 | MYSQL_PORT=${MYSQL_PORT:-3306} 13 | MYSQL_DATABASE=${MYSQL_DATABASE:-smsender} 14 | 15 | echo "Configure config.yml" 16 | if [[ ! -f ${config} ]]; then 17 | cp /config.default.yml ${config} 18 | sed -Ei "s/MYSQL_USER/${MYSQL_USER}/" ${config} 19 | sed -Ei "s/MYSQL_PASSWORD/${MYSQL_PASSWORD}/" ${config} 20 | sed -Ei "s/MYSQL_HOST/${MYSQL_HOST}/" ${config} 21 | sed -Ei "s/MYSQL_PORT/${MYSQL_PORT}/" ${config} 22 | sed -Ei "s/MYSQL_DATABASE/${MYSQL_DATABASE}/" ${config} 23 | echo OK 24 | else 25 | echo SKIP 26 | fi 27 | 28 | echo "Wait until database ${MYSQL_HOST}:${MYSQL_PORT} is ready..." 29 | until nc -z "${MYSQL_HOST}" "${MYSQL_PORT}" 30 | do 31 | sleep 1 32 | done 33 | 34 | sleep 1 35 | 36 | echo "Starting app" 37 | cd /smsender || exit 255 38 | ./smsender -c "${config}" 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Minchao 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /webroot/src/styles/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: 'Roboto', sans-serif; 3 | -webkit-font-smoothing: antialiased; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | font-size: 13px; 9 | line-height: 20px; 10 | } 11 | 12 | body, h1, h2, h3, h4, h5, h6 { 13 | margin: 0; 14 | } 15 | 16 | header { 17 | position: fixed; 18 | top: 0; 19 | right: 0; 20 | width: 100%; 21 | z-index: 100; 22 | } 23 | 24 | main { 25 | padding: 64px 0 40px 0; 26 | } 27 | 28 | main h2 { 29 | margin: 30px 0; 30 | padding: 10px 0; 31 | font-weight: 400; 32 | font-size: 28px; 33 | color: #000; 34 | border-bottom: 1px solid #eee; 35 | } 36 | 37 | main h3 { 38 | margin: 20px 0; 39 | font-size: 18px; 40 | font-weight: 400; 41 | } 42 | 43 | table.sms-table { 44 | width: 100%; 45 | } 46 | table.sms-table th, table.sms-table td { 47 | padding: 10px 0; 48 | text-align: left; 49 | } 50 | table.sms-table th { 51 | width: 20%; 52 | padding-right: 10px; 53 | font-size: 15px; 54 | font-weight: 500; 55 | color: #666; 56 | } 57 | table.sms-table td { 58 | padding: 10px; 59 | } -------------------------------------------------------------------------------- /smsender/store/memory/route_store.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/minchao/smsender/smsender/model" 7 | "github.com/minchao/smsender/smsender/store" 8 | ) 9 | 10 | type RouteStore struct { 11 | *Store 12 | routes []*model.Route 13 | sync.RWMutex 14 | } 15 | 16 | func NewMemoryRouteStore(memoryStore *Store) store.RouteStore { 17 | return &RouteStore{memoryStore, []*model.Route{}, sync.RWMutex{}} 18 | } 19 | 20 | func (s *RouteStore) GetAll() store.Channel { 21 | storeChannel := make(store.Channel, 1) 22 | 23 | go func() { 24 | result := store.Result{} 25 | 26 | s.Lock() 27 | defer s.Unlock() 28 | 29 | result.Data = s.routes 30 | 31 | storeChannel <- result 32 | close(storeChannel) 33 | }() 34 | 35 | return storeChannel 36 | } 37 | 38 | func (s *RouteStore) SaveAll(routes []*model.Route) store.Channel { 39 | storeChannel := make(store.Channel, 1) 40 | 41 | go func() { 42 | result := store.Result{} 43 | 44 | s.Lock() 45 | defer s.Unlock() 46 | 47 | s.routes = routes 48 | 49 | result.Data = routes 50 | 51 | storeChannel <- result 52 | close(storeChannel) 53 | }() 54 | 55 | return storeChannel 56 | } 57 | -------------------------------------------------------------------------------- /smsender/plugin/plugin.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/model" 5 | "github.com/minchao/smsender/smsender/store" 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | func init() { 10 | StoreFactories = map[string]StoreFactory{} 11 | ProviderFactories = map[string]ProviderFactory{} 12 | } 13 | 14 | // StoreFactory is a function that returns store.Store implementation. 15 | type StoreFactory func(config *viper.Viper) (store.Store, error) 16 | 17 | // StoreFactories is a map where store name matches StoreFactory. 18 | var StoreFactories map[string]StoreFactory 19 | 20 | // RegisterStore registers the specific StoreFactory. 21 | func RegisterStore(name string, fn StoreFactory) { 22 | StoreFactories[name] = fn 23 | } 24 | 25 | // ProviderFactory is a function that returns model.Provider implementation. 26 | type ProviderFactory func(config *viper.Viper) (model.Provider, error) 27 | 28 | // ProviderFactories is a map where provider name matches ProviderFactory. 29 | var ProviderFactories map[string]ProviderFactory 30 | 31 | // RegisterProvider registers the specific ProviderFactory. 32 | func RegisterProvider(name string, fn ProviderFactory) { 33 | ProviderFactories[name] = fn 34 | } 35 | -------------------------------------------------------------------------------- /config/config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "store": { 3 | "name": "sql", 4 | "sql": { 5 | "driver": "mysql", 6 | "dsn": "user:password@tcp(localhost:3306)/dbname?parseTime=true&loc=Local", 7 | "connection": { 8 | "maxOpenConns": 100, 9 | "maxIdleConns": 50 10 | } 11 | } 12 | }, 13 | "http": { 14 | "enable": true, 15 | "siteURL": "", 16 | "addr": ":8080", 17 | "tls": false, 18 | "tlsCertFile": "", 19 | "tlsKeyFile": "", 20 | "api": { 21 | "cors": { 22 | "enable": false, 23 | "origins": [], 24 | "headers": [ 25 | "*" 26 | ], 27 | "methods": [ 28 | "GET", 29 | "POST", 30 | "PUT", 31 | "PATCH", 32 | "DELETE" 33 | ], 34 | "debug": false 35 | } 36 | }, 37 | "web": { 38 | "enable": true 39 | } 40 | }, 41 | "worker": { 42 | "num": 100 43 | }, 44 | "providers": { 45 | "dummy": {} 46 | } 47 | } -------------------------------------------------------------------------------- /smsender/store/sql/store.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | _ "github.com/go-sql-driver/mysql" 5 | "github.com/jmoiron/sqlx" 6 | "github.com/minchao/smsender/smsender/plugin" 7 | "github.com/minchao/smsender/smsender/store" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func init() { 12 | plugin.RegisterStore("sql", Plugin) 13 | } 14 | 15 | func Plugin(config *viper.Viper) (store.Store, error) { 16 | return New(config) 17 | } 18 | 19 | type Store struct { 20 | db *sqlx.DB 21 | route store.RouteStore 22 | message store.MessageStore 23 | } 24 | 25 | func New(config *viper.Viper) (store.Store, error) { 26 | sqlStore := &Store{} 27 | 28 | db, err := sqlx.Connect(config.GetString("driver"), config.GetString("dsn")) 29 | if err != nil { 30 | return nil, err 31 | } 32 | db.SetMaxOpenConns(config.GetInt("connection.maxOpenConns")) 33 | db.SetMaxIdleConns(config.GetInt("connection.maxIdleConns")) 34 | 35 | sqlStore.db = db 36 | 37 | sqlStore.route = NewSQLRouteStore(sqlStore) 38 | sqlStore.message = NewSQLMessageStore(sqlStore) 39 | 40 | return sqlStore, nil 41 | } 42 | 43 | func (s *Store) Route() store.RouteStore { 44 | return s.route 45 | } 46 | 47 | func (s *Store) Message() store.MessageStore { 48 | return s.message 49 | } 50 | -------------------------------------------------------------------------------- /webroot/src/models/MessageModel.js: -------------------------------------------------------------------------------- 1 | import { action, observable } from 'mobx' 2 | 3 | export default class MessageModel { 4 | json 5 | @observable id 6 | @observable to 7 | @observable from 8 | @observable body 9 | @observable route 10 | @observable provider 11 | @observable status 12 | @observable created_time // eslint-disable-line 13 | 14 | @action fromJS (object) { 15 | this.json = object 16 | this.id = object.id 17 | this.to = object.to 18 | this.from = object.from 19 | this.body = object.body 20 | this.route = object.route 21 | this.provider = object.provider 22 | this.status = object.status 23 | this.original_message_id = object.original_message_id 24 | this.created_time = object.created_time 25 | 26 | return this 27 | } 28 | 29 | toJS () { 30 | return { 31 | id: this.id, 32 | to: this.to, 33 | from: this.from, 34 | body: this.body, 35 | route: this.route, 36 | provider: this.provider, 37 | status: this.status, 38 | original_message_id: this.original_message_id, 39 | created_time: this.created_time, 40 | json: this.json 41 | } 42 | } 43 | 44 | static new (object) { 45 | return (new MessageModel()).fromJS(object) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /webroot/src/components/Home.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import RaisedButton from 'material-ui/RaisedButton' 4 | 5 | const styles = { 6 | home: { 7 | paddingTop: 200, 8 | paddingBottom: 60, 9 | textAlign: 'center', 10 | backgroundColor: '#00bcd4', 11 | height: '100%', 12 | overflow: 'hidden' 13 | }, 14 | h1: { 15 | margin: 0, 16 | paddingBottom: 20, 17 | fontWeight: 300, 18 | fontSize: '50px', 19 | color: '#fff' 20 | }, 21 | h2: { 22 | paddingBottom: 20, 23 | fontWeight: 300, 24 | fontSize: '24px', 25 | lineHeight: '32px', 26 | color: '#fff', 27 | WebkitFontSmoothing: 'antialiased' 28 | } 29 | } 30 | 31 | @inject('routing') 32 | @observer 33 | export default class Home extends Component { 34 | render () { 35 | const {push} = this.props.routing 36 | 37 | return ( 38 |
39 |

SMSender Console

40 |

A SMS server written in Go

41 | push('console/sms')} 45 | /> 46 |
47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /smsender/api/utils.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "gopkg.in/go-playground/validator.v9" 10 | ) 11 | 12 | type errorMessage struct { 13 | Error string `json:"error"` 14 | ErrorDescription interface{} `json:"error_description,omitempty"` 15 | } 16 | 17 | func formErrorMessage(err error) errorMessage { 18 | var ( 19 | e = "bad_request" 20 | description interface{} 21 | ) 22 | switch err.(type) { 23 | case validator.ValidationErrors: 24 | errors := map[string]interface{}{} 25 | for _, v := range err.(validator.ValidationErrors) { 26 | errors[v.Field()] = fmt.Sprintf("Invalid validation on tag: %s", v.Tag()) 27 | } 28 | description = errors 29 | default: 30 | description = err.Error() 31 | } 32 | return errorMessage{Error: e, ErrorDescription: description} 33 | } 34 | 35 | func render(w http.ResponseWriter, code int, data interface{}) error { 36 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 37 | w.WriteHeader(code) 38 | if data == nil { 39 | return nil 40 | } 41 | return json.NewEncoder(w).Encode(data) 42 | } 43 | 44 | func cleanEmptyURLValues(values *url.Values) { 45 | for k := range *values { 46 | if values.Get(k) == "" { 47 | delete(*values, k) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /smsender/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/minchao/smsender/smsender" 8 | log "github.com/sirupsen/logrus" 9 | config "github.com/spf13/viper" 10 | "github.com/urfave/negroni" 11 | ) 12 | 13 | // InitWeb initializes the web server. 14 | func InitWeb(sender *smsender.Sender) { 15 | log.Debug("web.InitWeb") 16 | 17 | if config.GetBool("http.web.enable") { 18 | router := sender.HTTPRouter 19 | 20 | router.PathPrefix("/dist/"). 21 | Handler(staticHandler(http.StripPrefix("/dist/", http.FileServer(http.Dir("./webroot/dist"))))) 22 | 23 | n := negroni.New(negroni.Wrap(http.HandlerFunc(root))) 24 | 25 | router.Handle("/", n).Methods("GET") 26 | router.Handle("/{anything:.*}", n).Methods("GET") 27 | } 28 | } 29 | 30 | func root(w http.ResponseWriter, r *http.Request) { 31 | w.Header().Set("Cache-Control", "no-cache, max-age=31556926, public") 32 | http.ServeFile(w, r, "./webroot/dist/index.html") 33 | } 34 | 35 | func staticHandler(handler http.Handler) http.Handler { 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | w.Header().Set("Cache-Control", "max-age=31556926, public") 38 | if strings.HasSuffix(r.URL.Path, "/") { 39 | http.NotFound(w, r) 40 | return 41 | } 42 | handler.ServeHTTP(w, r) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /smsender/model/route.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "regexp" 4 | 5 | type Route struct { 6 | ID int64 `json:"-"` 7 | Name string `json:"name"` 8 | Pattern string `json:"pattern"` 9 | Provider string `json:"provider"` 10 | From string `json:"from" db:"fromName"` 11 | IsActive bool `json:"is_active" db:"isActive"` 12 | provider Provider 13 | regex *regexp.Regexp 14 | } 15 | 16 | // NewRoute creates a new instance of the Route. 17 | func NewRoute(name, pattern string, provider Provider, isActive bool) *Route { 18 | return &Route{ 19 | Name: name, 20 | Pattern: pattern, 21 | Provider: provider.Name(), 22 | IsActive: isActive, 23 | provider: provider, 24 | regex: regexp.MustCompile(pattern), 25 | } 26 | } 27 | 28 | func (r *Route) SetPattern(pattern string) *Route { 29 | r.Pattern = pattern 30 | r.regex = regexp.MustCompile(pattern) 31 | return r 32 | } 33 | 34 | func (r *Route) SetProvider(provider Provider) *Route { 35 | r.Provider = provider.Name() 36 | r.provider = provider 37 | return r 38 | } 39 | 40 | func (r *Route) GetProvider() Provider { 41 | return r.provider 42 | } 43 | 44 | func (r *Route) SetFrom(from string) *Route { 45 | r.From = from 46 | return r 47 | } 48 | 49 | // Match matches the route against the recipient phone number. 50 | func (r *Route) Match(recipient string) bool { 51 | return r.IsActive && r.regex.MatchString(recipient) 52 | } 53 | -------------------------------------------------------------------------------- /webroot/src/stores/RouteStore.js: -------------------------------------------------------------------------------- 1 | import { action, observable } from 'mobx' 2 | 3 | import api from './API' 4 | import RouteModel from '../models/RouteModel' 5 | 6 | export default class RouteStore { 7 | @observable routes = [] 8 | @observable providers = [] 9 | 10 | @action add (route) { 11 | this.routes.push(RouteModel.new(route)) 12 | } 13 | 14 | @action clear () { 15 | this.routes = [] 16 | this.providers = [] 17 | } 18 | 19 | @action initData (json) { 20 | this.clear() 21 | json.data.map(route => this.add(route)) 22 | json.providers.map(provider => this.providers.push(provider.name)) 23 | } 24 | 25 | sync () { 26 | api.getRoutes((json) => { 27 | this.initData(json) 28 | }) 29 | } 30 | 31 | getByName (name) { 32 | for (let i = 0; i < this.routes.length; i++) { 33 | if (this.routes[i].name === name) { 34 | return this.routes[i] 35 | } 36 | } 37 | return null 38 | } 39 | 40 | create (route) { 41 | api.postRoute(route, (json) => { 42 | this.sync() 43 | }) 44 | } 45 | 46 | update (route) { 47 | api.putRoute(route, (json) => { 48 | this.sync() 49 | }) 50 | } 51 | 52 | reorder (rangeStart, rangeLength, insertBefore) { 53 | api.reorderRoutes(rangeStart, rangeLength, insertBefore, (json) => { 54 | this.initData(json) 55 | }) 56 | } 57 | 58 | del (name) { 59 | api.deleteRoute(name, (json) => { 60 | this.sync() 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /smsender/cmd/smsender.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | "time" 8 | 9 | "github.com/minchao/smsender/smsender" 10 | "github.com/minchao/smsender/smsender/api" 11 | "github.com/minchao/smsender/smsender/web" 12 | log "github.com/sirupsen/logrus" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // Execute executes the smsender command. 17 | func Execute() { 18 | var configFile string 19 | var debug bool 20 | 21 | var rootCmd = &cobra.Command{ 22 | Use: "smsender", 23 | Short: "smsender", 24 | Long: "A SMS server written in Go (Golang)", 25 | Run: rootCmdF, 26 | } 27 | 28 | rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Configuration file path") 29 | rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug mode") 30 | 31 | rootCmd.AddCommand(sendCmd, routesCmd) 32 | 33 | _ = rootCmd.Execute() 34 | } 35 | 36 | func rootCmdF(cmd *cobra.Command, args []string) { 37 | if err := initEnv(cmd); err != nil { 38 | log.Fatalln(err) 39 | return 40 | } 41 | 42 | sender := smsender.NewSender() 43 | api.InitAPI(sender) 44 | web.InitWeb(sender) 45 | 46 | go handleSignals(sender) 47 | 48 | sender.Run() 49 | } 50 | 51 | func handleSignals(s *smsender.Sender) { 52 | c := make(chan os.Signal, 1) 53 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 54 | 55 | <-c 56 | 57 | log.Infoln("Shutting down") 58 | go time.AfterFunc(60*time.Second, func() { 59 | os.Exit(1) 60 | }) 61 | s.Shutdown() 62 | os.Exit(0) 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/minchao/smsender 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 // indirect 7 | github.com/aws/aws-sdk-go v1.16.18 8 | github.com/carlosdp/twiliogo v0.0.0-20140102225436-f61c8230fa91 9 | github.com/go-playground/form v3.1.3+incompatible 10 | github.com/go-playground/locales v0.12.1 // indirect 11 | github.com/go-playground/universal-translator v0.16.0 // indirect 12 | github.com/go-sql-driver/mysql v1.4.1 13 | github.com/gorilla/context v1.1.1 // indirect 14 | github.com/gorilla/mux v1.6.2 15 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 16 | github.com/jmoiron/sqlx v1.2.0 17 | github.com/leodido/go-urn v1.1.0 // indirect 18 | github.com/rs/cors v1.6.0 19 | github.com/rs/xid v1.2.1 20 | github.com/sirupsen/logrus v1.3.0 21 | github.com/spf13/afero v1.2.0 // indirect 22 | github.com/spf13/cobra v0.0.3 23 | github.com/spf13/viper v1.3.1 24 | github.com/stretchr/testify v1.3.0 // indirect 25 | github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 // indirect 26 | github.com/ttacon/libphonenumber v1.0.1 27 | github.com/urfave/negroni v1.0.0 28 | golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc // indirect 29 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect 30 | golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb // indirect 31 | google.golang.org/appengine v1.4.0 // indirect 32 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 33 | gopkg.in/go-playground/validator.v9 v9.25.0 34 | gopkg.in/njern/gonexmo.v1 v1.0.1 35 | ) 36 | -------------------------------------------------------------------------------- /smsender/utils/validate.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "reflect" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/ttacon/libphonenumber" 11 | "gopkg.in/go-playground/validator.v9" 12 | ) 13 | 14 | func NewValidate() *validator.Validate { 15 | validate := validator.New() 16 | validate.RegisterTagNameFunc(func(fld reflect.StructField) string { 17 | name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] 18 | if name == "-" { 19 | return "" 20 | } 21 | return name 22 | }) 23 | return validate 24 | } 25 | 26 | func IsPhoneNumber(fl validator.FieldLevel) bool { 27 | phone, err := libphonenumber.Parse(fl.Field().String(), "") 28 | if err != nil { 29 | return false 30 | } 31 | if !libphonenumber.IsValidNumber(phone) { 32 | return false 33 | } 34 | return true 35 | } 36 | 37 | func IsTimeRFC3339(fl validator.FieldLevel) bool { 38 | if _, err := time.Parse(time.RFC3339, fl.Field().String()); err != nil { 39 | return false 40 | } 41 | return true 42 | } 43 | 44 | func IsTimeUnixMicro(fl validator.FieldLevel) bool { 45 | field := fl.Field() 46 | 47 | switch field.Kind() { 48 | case reflect.Int64: 49 | s := strconv.FormatInt(field.Int(), 10) 50 | if len(s) == 16 { 51 | return true 52 | } 53 | case reflect.String: 54 | if matched, _ := regexp.MatchString(`^\d{16}$`, fl.Field().String()); matched { 55 | return true 56 | } 57 | } 58 | return false 59 | } 60 | 61 | func IsRegexp(fl validator.FieldLevel) bool { 62 | if _, err := regexp.Compile(fl.Field().String()); err != nil { 63 | return false 64 | } 65 | return true 66 | } 67 | -------------------------------------------------------------------------------- /webroot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smsender-web", 3 | "private": true, 4 | "dependencies": { 5 | "material-ui": "^0.16.7", 6 | "mobx": "^3.2.1", 7 | "mobx-react": "^4.2.2", 8 | "mobx-react-devtools": "^4.2.15", 9 | "mobx-react-router": "^3.1.2", 10 | "react": "^15.6.1", 11 | "react-dom": "^15.6.1", 12 | "react-router": "^3.0.5", 13 | "react-syntax-highlighter": "^5.6.2", 14 | "react-tap-event-plugin": "^2.0.1" 15 | }, 16 | "devDependencies": { 17 | "babel-core": "^6.25.0", 18 | "babel-eslint": "^8.2.6", 19 | "babel-loader": "^7.1.5", 20 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 21 | "babel-preset-es2015": "^6.24.1", 22 | "babel-preset-react": "^6.24.1", 23 | "babel-preset-stage-1": "^6.24.1", 24 | "css-loader": "^2.1.1", 25 | "eslint": "^6.0.1", 26 | "eslint-config-standard": "^13.0.0", 27 | "eslint-config-standard-jsx": "^7.0.0", 28 | "eslint-plugin-import": "^2.18.0", 29 | "eslint-plugin-node": "^9.1.0", 30 | "eslint-plugin-react": "^7.14.2", 31 | "eslint-plugin-standard": "^4.0.0", 32 | "html-webpack-plugin": "^3.2.0", 33 | "mini-css-extract-plugin": "^0.5.0", 34 | "react-hot-loader": "^3.0.0-beta.7", 35 | "standard": "^13.0.0", 36 | "style-loader": "^0.23.1", 37 | "webpack": "^4.28.4", 38 | "webpack-cli": "^3.2.1", 39 | "webpack-dev-server": "^3.1.14" 40 | }, 41 | "scripts": { 42 | "build": "NODE_ENV=production webpack", 43 | "watch": "NODE_ENV=production webpack --watch", 44 | "dev": "NODE_ENV=development node webpack-dev-server", 45 | "lint": "eslint './src/**/*.js{,x}'" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | HOME="/home/ubuntu" 4 | WORKSPACE="${HOME}/go/src/github.com/minchao/smsender" 5 | CONFIG="${WORKSPACE}/config/config.yml" 6 | MYSQL_USER=${MYSQL_USER:-smsender_user} 7 | MYSQL_PASSWORD=${MYSQL_PASSWORD:-smsender_password} 8 | MYSQL_HOST=${MYSQL_HOST:-127.0.0.1} 9 | MYSQL_PORT=${MYSQL_PORT:-3306} 10 | MYSQL_DATABASE=${MYSQL_DATABASE:-smsender} 11 | 12 | sudo apt update 13 | sudo apt upgrade -y 14 | sudo apt install -y git curl make 15 | curl -sSL https://get.docker.com/ | sudo sh 16 | sudo usermod -aG docker ubuntu 17 | sudo apt install -y docker-compose 18 | 19 | echo "Installing Go" 20 | 21 | if [ ! -e "/vagrant/go.tar.gz" ]; then 22 | curl https://storage.googleapis.com/golang/go1.9.linux-amd64.tar.gz -o /vagrant/go.tar.gz 23 | fi; 24 | sudo tar -C /usr/local -xzf /vagrant/go.tar.gz 25 | 26 | echo "Creating workspace" 27 | 28 | mkdir -p "${HOME}/go/src/github.com/minchao" 29 | ln -s /vagrant "${WORKSPACE}" 30 | 31 | echo "Setting up development environment" 32 | 33 | echo 'export PATH=$PATH:/usr/local/go/bin:~/go/bin' >> "${HOME}/.profile" 34 | echo '# Automatically chdir to workspace upon vagrant ssh' >> "${HOME}/.profile" 35 | echo "cd ${WORKSPACE}" >> "${HOME}/.profile" 36 | source "${HOME}/.profile" 37 | 38 | cd "${WORKSPACE}" 39 | 40 | echo "Build app" 41 | 42 | make build 43 | 44 | echo "Configure config.yml" 45 | if [ ! -f ${CONFIG} ] 46 | then 47 | cp ./config/config.default.yml ${CONFIG} 48 | sed -Ei "s/MYSQL_USER/$MYSQL_USER/" ${CONFIG} 49 | sed -Ei "s/MYSQL_PASSWORD/$MYSQL_PASSWORD/" ${CONFIG} 50 | sed -Ei "s/MYSQL_HOST/$MYSQL_HOST/" ${CONFIG} 51 | sed -Ei "s/MYSQL_PORT/$MYSQL_PORT/" ${CONFIG} 52 | sed -Ei "s/MYSQL_DATABASE/$MYSQL_DATABASE/" ${CONFIG} 53 | echo OK 54 | fi 55 | 56 | echo "Starting docker-compose-dev.yml" 57 | 58 | sudo docker-compose -f docker-compose-dev.yml up 59 | -------------------------------------------------------------------------------- /webroot/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Provider } from 'mobx-react' 3 | import { useStrict } from 'mobx' 4 | import DevTools from 'mobx-react-devtools' 5 | import { RouterStore, syncHistoryWithStore } from 'mobx-react-router' 6 | import { browserHistory, Route, Router } from 'react-router' 7 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' 8 | import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme' 9 | import getMuiTheme from 'material-ui/styles/getMuiTheme' 10 | 11 | import Home from './components/Home' 12 | import Console from './components/Console' 13 | import RouterPage from './components/router/RouterPage' 14 | import SMSPage from './components/sms/SMSPage' 15 | import SendPage from './components/sms/SendPage' 16 | import DetailsPage from './components/sms/DetailsPage' 17 | 18 | useStrict(true) 19 | 20 | const routingStore = new RouterStore() 21 | 22 | const stores = { 23 | routing: routingStore 24 | } 25 | 26 | const history = syncHistoryWithStore(browserHistory, routingStore) 27 | 28 | export default class App extends Component { 29 | render () { 30 | return ( 31 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {__DEVELOPMENT__ && } 45 |
46 |
47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /webroot/src/stores/MessageStore.js: -------------------------------------------------------------------------------- 1 | import { action, observable } from 'mobx' 2 | 3 | import api from './API' 4 | import MessageModel from '../models/MessageModel' 5 | 6 | export default class MessageStore { 7 | @observable messages = [] 8 | @observable since = null 9 | @observable until = null 10 | 11 | @action clear () { 12 | this.messages = [] 13 | this.since = null 14 | this.until = null 15 | } 16 | 17 | @action initData (json) { 18 | this.clear() 19 | 20 | const rows = [] 21 | json.data.map(message => rows.push(MessageModel.new(message))) 22 | this.messages = rows 23 | if (json.hasOwnProperty('paging')) { 24 | if (json.paging.hasOwnProperty('previous')) { 25 | this.since = json.paging.previous 26 | } 27 | if (json.paging.hasOwnProperty('next')) { 28 | this.until = json.paging.next 29 | } 30 | } 31 | } 32 | 33 | find (messageId = '') { 34 | api.getMessagesByIds(messageId, (json) => { 35 | this.initData(json) 36 | }) 37 | } 38 | 39 | sync (query = '') { 40 | api.getMessages(query, (json) => { 41 | this.initData(json) 42 | }) 43 | } 44 | 45 | search (to, status, since, until, limit) { 46 | this.sync(this.buildQueryString(to, status, since, until, limit)) 47 | } 48 | 49 | buildQueryString (to = '', status = '', since = '', until = '', limit = 20) { 50 | let query = '' 51 | query += andWhere(query, 'to', to.replace('+', '%2B')) 52 | query += andWhere(query, 'status', status) 53 | query += andWhere(query, 'since', since) 54 | query += andWhere(query, 'until', until) 55 | query += andWhere(query, 'limit', limit) 56 | 57 | return '?' + query 58 | } 59 | } 60 | 61 | function andWhere (query, where, value) { 62 | if (!value) { 63 | return '' 64 | } 65 | 66 | where = where + '=' + value 67 | 68 | if (query) { 69 | where = '&' + where 70 | } 71 | return where 72 | } 73 | -------------------------------------------------------------------------------- /smsender/store/sql/route_store.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/model" 5 | "github.com/minchao/smsender/smsender/store" 6 | ) 7 | 8 | const SQLRouteTable = ` 9 | CREATE TABLE IF NOT EXISTS route ( 10 | id int(11) NOT NULL AUTO_INCREMENT, 11 | name varchar(32) COLLATE utf8_unicode_ci NOT NULL, 12 | pattern varchar(32) COLLATE utf8_unicode_ci NOT NULL, 13 | provider varchar(32) COLLATE utf8_unicode_ci NOT NULL, 14 | fromName varchar(20) COLLATE utf8_unicode_ci NOT NULL, 15 | isActive tinyint(1) NOT NULL DEFAULT '0', 16 | PRIMARY KEY (id), 17 | UNIQUE KEY name (name) 18 | ) DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci` 19 | 20 | type RouteStore struct { 21 | *Store 22 | } 23 | 24 | func NewSQLRouteStore(sqlStore *Store) store.RouteStore { 25 | rs := &RouteStore{sqlStore} 26 | 27 | rs.db.MustExec(SQLRouteTable) 28 | 29 | return rs 30 | } 31 | 32 | func (rs *RouteStore) GetAll() store.Channel { 33 | storeChannel := make(store.Channel, 1) 34 | 35 | go func() { 36 | result := store.Result{} 37 | var routes []*model.Route 38 | if err := rs.db.Select(&routes, `SELECT * FROM route ORDER BY id ASC`); err != nil { 39 | result.Err = err 40 | } else { 41 | result.Data = routes 42 | } 43 | 44 | storeChannel <- result 45 | close(storeChannel) 46 | }() 47 | 48 | return storeChannel 49 | } 50 | 51 | func (rs *RouteStore) SaveAll(routes []*model.Route) store.Channel { 52 | storeChannel := make(store.Channel, 1) 53 | 54 | go func() { 55 | result := store.Result{} 56 | 57 | tx := rs.db.MustBegin() 58 | tx.MustExec(`TRUNCATE TABLE route`) 59 | for _, route := range routes { 60 | tx.MustExec(`INSERT INTO route 61 | (name, pattern, provider, fromName, isActive) 62 | VALUES (?, ?, ?, ?, ?)`, 63 | route.Name, route.Pattern, route.Provider, route.From, route.IsActive) 64 | } 65 | if err := tx.Commit(); err != nil { 66 | result.Err = err 67 | } else { 68 | result.Data = routes 69 | } 70 | 71 | storeChannel <- result 72 | close(storeChannel) 73 | }() 74 | 75 | return storeChannel 76 | } 77 | -------------------------------------------------------------------------------- /smsender/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/minchao/smsender/smsender" 6 | "github.com/minchao/smsender/smsender/model" 7 | "github.com/rs/cors" 8 | log "github.com/sirupsen/logrus" 9 | config "github.com/spf13/viper" 10 | "github.com/urfave/negroni" 11 | ) 12 | 13 | // Server represents a web API server. 14 | type Server struct { 15 | sender *smsender.Sender 16 | out chan<- *model.MessageJob 17 | } 18 | 19 | // InitAPI initializes the web API server. 20 | func InitAPI(sender *smsender.Sender) *Server { 21 | log.Debug("api.InitAPI") 22 | 23 | server := Server{ 24 | sender: sender, 25 | out: sender.GetMessagesChannel(), 26 | } 27 | server.init() 28 | return &server 29 | } 30 | 31 | func (s *Server) init() { 32 | router := mux.NewRouter().PathPrefix("/api").Subrouter().StrictSlash(true) 33 | router.HandleFunc("/", s.Hello).Methods("GET") 34 | router.HandleFunc("/routes", s.Routes).Methods("GET") 35 | router.HandleFunc("/routes", s.RoutePost).Methods("POST") 36 | router.HandleFunc("/routes", s.RouteReorder).Methods("PUT") 37 | router.HandleFunc("/routes/{route}", s.RoutePut).Methods("PUT") 38 | router.HandleFunc("/routes/{route}", s.RouteDelete).Methods("DELETE") 39 | router.HandleFunc("/routes/test/{phone}", s.RouteTest).Methods("GET") 40 | router.HandleFunc("/messages", s.Messages).Methods("GET") 41 | router.HandleFunc("/messages/byIds", s.MessagesGetByIds).Methods("GET") 42 | router.HandleFunc("/messages", s.MessagesPost).Methods("POST") 43 | router.HandleFunc("/stats", s.Stats).Methods("GET") 44 | 45 | n := negroni.New() 46 | n.UseFunc(s.ShutdownMiddleware) 47 | 48 | if config.GetBool("http.api.cors.enable") { 49 | n.Use(cors.New(cors.Options{ 50 | AllowedOrigins: config.GetStringSlice("http.api.cors.origins"), 51 | AllowedHeaders: config.GetStringSlice("http.api.cors.headers"), 52 | AllowedMethods: config.GetStringSlice("http.api.cors.methods"), 53 | Debug: config.GetBool("http.api.cors.debug"), 54 | })) 55 | } 56 | 57 | n.UseHandler(router) 58 | 59 | s.sender.HTTPRouter.PathPrefix("/api").Handler(n) 60 | } 61 | -------------------------------------------------------------------------------- /smsender/cmd/send.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/minchao/smsender/smsender" 8 | "github.com/minchao/smsender/smsender/model" 9 | "github.com/minchao/smsender/smsender/utils" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | config "github.com/spf13/viper" 13 | "gopkg.in/go-playground/validator.v9" 14 | ) 15 | 16 | var sendCmd = &cobra.Command{ 17 | Use: "send", 18 | Short: "Send message", 19 | Example: ` send --to +12345678900 --body "Hello, 世界" 20 | send --to +12345678900 --from smsender --body "Hello, 世界" --provider dummy`, 21 | RunE: sendCmdF, 22 | } 23 | 24 | func init() { 25 | sendCmd.Flags().StringP("to", "t", "", "The destination phone number (E.164 format)") 26 | sendCmd.Flags().StringP("from", "f", "", "Sender Id (phone number or alphanumeric)") 27 | sendCmd.Flags().StringP("body", "b", "", "The text of the message") 28 | sendCmd.Flags().StringP("provider", "p", "", "Provider name") 29 | } 30 | 31 | func sendCmdF(cmd *cobra.Command, args []string) error { 32 | if err := initEnv(cmd); err != nil { 33 | return err 34 | } 35 | 36 | to, err := cmd.Flags().GetString("to") 37 | if err != nil || to == "" { 38 | return errors.New("The to is required") 39 | } 40 | validate := validator.New() 41 | _ = validate.RegisterValidation("phone", utils.IsPhoneNumber) 42 | if err := validate.Var(to, "phone"); err != nil { 43 | return errors.New("Invalid phone number") 44 | } 45 | from, _ := cmd.Flags().GetString("from") 46 | body, err := cmd.Flags().GetString("body") 47 | if err != nil || body == "" { 48 | return errors.New("The body is required") 49 | } 50 | provider, _ := cmd.Flags().GetString("provider") 51 | 52 | config.Set("worker.num", 1) 53 | 54 | sender := smsender.NewSender() 55 | sender.InitWorkers() 56 | 57 | job := model.NewMessageJob(to, from, body, false) 58 | if provider != "" { 59 | job.Provider = &provider 60 | } 61 | 62 | queue := sender.GetMessagesChannel() 63 | queue <- job 64 | 65 | result := <-job.Result 66 | resultJSON, _ := json.MarshalIndent(result, "", " ") 67 | 68 | log.Infof("Result:\n%s", resultJSON) 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /smsender/providers/aws/provider.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/credentials" 6 | "github.com/aws/aws-sdk-go/aws/session" 7 | "github.com/aws/aws-sdk-go/service/sns" 8 | "github.com/minchao/smsender/smsender/model" 9 | "github.com/minchao/smsender/smsender/plugin" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | const name = "aws" 14 | 15 | func init() { 16 | plugin.RegisterProvider(name, Plugin) 17 | } 18 | 19 | func Plugin(config *viper.Viper) (model.Provider, error) { 20 | return Config{ 21 | Region: config.GetString("region"), 22 | ID: config.GetString("id"), 23 | Secret: config.GetString("secret"), 24 | }.New(name) 25 | } 26 | 27 | type Provider struct { 28 | name string 29 | svc *sns.SNS 30 | } 31 | 32 | type Config struct { 33 | Region string 34 | ID string 35 | Secret string 36 | } 37 | 38 | // New creates AWS Provider. 39 | func (c Config) New(name string) (*Provider, error) { 40 | sess, err := session.NewSession(&aws.Config{ 41 | Region: aws.String(c.Region), 42 | Credentials: credentials.NewStaticCredentials( 43 | c.ID, 44 | c.Secret, 45 | "", 46 | ), 47 | }) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return &Provider{ 53 | name: name, 54 | svc: sns.New(sess), 55 | }, nil 56 | } 57 | 58 | func (b Provider) Name() string { 59 | return b.name 60 | } 61 | 62 | func (b Provider) Send(msg model.Message) *model.MessageResponse { 63 | req, resp := b.svc.PublishRequest(&sns.PublishInput{ 64 | Message: aws.String(msg.Body), 65 | MessageAttributes: map[string]*sns.MessageAttributeValue{ 66 | "Key": { // Required 67 | DataType: aws.String("String"), // Required 68 | StringValue: aws.String("String"), 69 | }, 70 | }, 71 | PhoneNumber: aws.String(msg.To), 72 | }) 73 | 74 | err := req.Send() 75 | 76 | if err != nil { 77 | return model.NewMessageResponse(model.StatusFailed, model.ProviderError{Error: err.Error()}, nil) 78 | } 79 | 80 | return model.NewMessageResponse(model.StatusSent, resp, resp.MessageId) 81 | } 82 | 83 | // Callback TODO: see http://docs.aws.amazon.com/sns/latest/dg/sms_stats_usage.html 84 | func (b Provider) Callback(register func(webhook *model.Webhook), receiptsCh chan<- model.MessageReceipt) { 85 | } 86 | -------------------------------------------------------------------------------- /webroot/src/stores/API.js: -------------------------------------------------------------------------------- 1 | class API { 2 | constructor () { 3 | this.baseURL = API_HOST 4 | } 5 | 6 | fetch (url, object, callback) { 7 | fetch(this.baseURL + url, object) 8 | .then(response => { 9 | if (response.ok) return response.json() 10 | return response.json() 11 | }) 12 | .then(json => { 13 | return callback(json) 14 | }) 15 | .catch(() => { 16 | return callback(null) 17 | }) 18 | } 19 | 20 | getRoutes (callback) { 21 | this.fetch('/api/routes', {method: 'get'}, callback) 22 | } 23 | 24 | postRoute (route, callback) { 25 | this.fetch('/api/routes', { 26 | method: 'post', 27 | body: JSON.stringify(route), 28 | headers: new Headers({'Content-Type': 'application/json'}) 29 | }, callback) 30 | } 31 | 32 | putRoute (route, callback) { 33 | this.fetch('/api/routes/' + route.name, { 34 | method: 'put', 35 | body: JSON.stringify(route), 36 | headers: new Headers({'Content-Type': 'application/json'}) 37 | }, callback) 38 | } 39 | 40 | reorderRoutes (rangeStart, rangeLength, insertBefore, callback) { 41 | this.fetch('/api/routes', { 42 | method: 'put', 43 | body: JSON.stringify({ 44 | 'range_start': rangeStart, 45 | 'range_length': rangeLength, 46 | 'insert_before': insertBefore 47 | }), 48 | headers: new Headers({'Content-Type': 'application/json'}) 49 | }, callback) 50 | } 51 | 52 | deleteRoute (name, callback) { 53 | this.fetch('/api/routes/' + name, {method: 'delete'}, callback) 54 | } 55 | 56 | postMessage (to, from, body, callback) { 57 | this.fetch('/api/messages', { 58 | method: 'post', 59 | body: JSON.stringify({ 60 | 'to': [to], 61 | 'from': from, 62 | 'body': body 63 | }), 64 | headers: new Headers({'Content-Type': 'application/json'}) 65 | }, callback) 66 | } 67 | 68 | getMessages (query, callback) { 69 | this.fetch('/api/messages' + query, {method: 'get'}, callback) 70 | } 71 | 72 | getMessagesByIds (messageIds, callback) { 73 | this.fetch('/api/messages/byIds?ids=' + messageIds, {method: 'get'}, callback) 74 | } 75 | } 76 | 77 | const api = new API() 78 | 79 | export default api 80 | export { API } 81 | -------------------------------------------------------------------------------- /webroot/src/components/Console.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import AppBar from 'material-ui/AppBar' 4 | import Drawer from 'material-ui/Drawer' 5 | import FlatButton from 'material-ui/FlatButton' 6 | import Menu from 'material-ui/Menu' 7 | import MenuItem from 'material-ui/MenuItem' 8 | import { minBlack } from 'material-ui/styles/colors' 9 | import SvgMessage from 'material-ui/svg-icons/communication/message' 10 | import SvgList from 'material-ui/svg-icons/action/list' 11 | 12 | @inject('routing') 13 | @observer 14 | export default class Routes extends Component { 15 | constructor (props) { 16 | super(props) 17 | this.menuItemStyle = this.menuItemStyle.bind(this) 18 | } 19 | 20 | menuItemStyle (targetPath) { 21 | if (this.props.routing.location.pathname === targetPath) { 22 | return {backgroundColor: minBlack} 23 | } 24 | return null 25 | } 26 | 27 | render () { 28 | const {push} = this.props.routing 29 | 30 | return ( 31 |
32 |
33 | push('/')} />} /> 34 |
35 |
36 | {this.props.children} 37 |
38 | 39 | 43 | 44 | 45 | push('/console/sms')} 47 | leftIcon={} 48 | >SMS 49 | push('/console/sms/send')} 51 | style={this.menuItemStyle('/console/sms/send')} 52 | insetChildren 53 | >Send an SMS 54 | push('/console/sms')} 56 | style={this.menuItemStyle('/console/sms')} 57 | insetChildren 58 | >Delivery Logs 59 | push('/console/router')} 61 | style={this.menuItemStyle('/console/router')} 62 | leftIcon={} 63 | >Router 64 | 65 | 66 |
67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /smsender/model/json.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "errors" 7 | ) 8 | 9 | // JSON is a json.RawMessage, which is a []byte underneath. 10 | // Value() validates the json format in the source, and returns an error if 11 | // the json is not valid. Scan does no validation. JSON additionally 12 | // implements `Unmarshal`, which unmarshals the json within to an interface{} 13 | // 14 | // See https://github.com/jmoiron/sqlx/blob/master/types/types.go 15 | type JSON json.RawMessage 16 | 17 | var emptyJSON = JSON("null") 18 | 19 | // MarshalJSON returns the *j as the JSON encoding of j. 20 | func (j JSON) MarshalJSON() ([]byte, error) { 21 | if len(j) == 0 { 22 | return emptyJSON, nil 23 | } 24 | return j, nil 25 | } 26 | 27 | // UnmarshalJSON sets *j to a copy of data 28 | func (j *JSON) UnmarshalJSON(data []byte) error { 29 | if j == nil { 30 | return errors.New("JSON: UnmarshalJSON on nil pointer") 31 | } 32 | *j = append((*j)[0:0], data...) 33 | return nil 34 | } 35 | 36 | // Value returns j as a value. This does a validating unmarshal into another 37 | // RawMessage. If j is invalid json, it returns an error. 38 | func (j JSON) Value() (driver.Value, error) { 39 | var m json.RawMessage 40 | var err = j.Unmarshal(&m) 41 | if err != nil { 42 | return []byte{}, err 43 | } 44 | return []byte(j), nil 45 | } 46 | 47 | // Scan stores the src in *j. No validation is done. 48 | func (j *JSON) Scan(src interface{}) error { 49 | var source []byte 50 | switch t := src.(type) { 51 | case string: 52 | source = []byte(t) 53 | case []byte: 54 | if len(t) == 0 { 55 | source = emptyJSON 56 | } else { 57 | source = t 58 | } 59 | case nil: 60 | *j = emptyJSON 61 | default: 62 | return errors.New("Incompatible type for JSON") 63 | } 64 | *j = JSON(append((*j)[0:0], source...)) 65 | return nil 66 | } 67 | 68 | // Unmarshal unmarshal's the json in j to v, as in json.Unmarshal. 69 | func (j *JSON) Unmarshal(v interface{}) error { 70 | if len(*j) == 0 { 71 | *j = emptyJSON 72 | } 73 | return json.Unmarshal([]byte(*j), v) 74 | } 75 | 76 | // String supports pretty printing for JSON types. 77 | func (j JSON) String() string { 78 | return string(j) 79 | } 80 | 81 | // MarshalJSON returns the JSON encoding of v. 82 | func MarshalJSON(v interface{}) JSON { 83 | b, err := json.Marshal(v) 84 | if err != nil { 85 | return []byte{} 86 | } 87 | return b 88 | } 89 | -------------------------------------------------------------------------------- /webroot/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 5 | 6 | module.exports = () => { 7 | const env = process.env.NODE_ENV 8 | const mode = (env === 'production') ? 'production' : 'development' 9 | const ifProd = plugin => (mode === 'production') ? plugin : undefined 10 | const ifDev = plugin => (mode === 'development') ? plugin : undefined 11 | const removeEmpty = array => array.filter(p => !!p) 12 | 13 | return { 14 | devtool: ifDev('source-map'), 15 | mode: mode, 16 | entry: { 17 | main: removeEmpty([ 18 | ifDev('react-hot-loader/patch'), 19 | ifDev('webpack-dev-server/client?http://localhost:3000'), 20 | ifDev('webpack/hot/only-dev-server'), 21 | path.join(__dirname, './src/index.jsx') 22 | ]) 23 | }, 24 | resolve: { 25 | extensions: ['.js', '.jsx'] 26 | }, 27 | output: { 28 | filename: '[name].[hash].js', 29 | sourceMapFilename: '[name].[hash].map.js', 30 | path: path.resolve(__dirname, 'dist'), 31 | publicPath: '/dist/' 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.jsx?$/, 37 | exclude: /node_modules/, 38 | loader: ['babel-loader'] 39 | }, 40 | { 41 | test: /\.(css)$/, 42 | use: 43 | [ 44 | (mode === 'development') ? 'style-loader' : MiniCssExtractPlugin.loader, 45 | 'css-loader?modules=true', 46 | ] 47 | } 48 | ] 49 | }, 50 | optimization: { 51 | minimize: mode === 'production', 52 | splitChunks: { 53 | chunks: 'all' 54 | } 55 | }, 56 | plugins: removeEmpty([ 57 | new HtmlWebpackPlugin({ 58 | template: path.join(__dirname, './src/index.html'), 59 | filename: 'index.html', 60 | inject: 'body' 61 | }), 62 | new webpack.DefinePlugin({ 63 | __DEVELOPMENT__: mode === 'development', 64 | API_HOST: JSON.stringify(mode === 'development' ? 'http://localhost:8080' : '') 65 | }), 66 | ifDev(new webpack.HotModuleReplacementPlugin()), 67 | ifDev(new webpack.NamedModulesPlugin()), 68 | ifProd(new MiniCssExtractPlugin({ 69 | filename: '[name].[hash].css' 70 | })) 71 | ]) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /smsender/worker.go: -------------------------------------------------------------------------------- 1 | package smsender 2 | 3 | import ( 4 | "github.com/minchao/smsender/smsender/model" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | type worker struct { 9 | id int 10 | sender *Sender 11 | } 12 | 13 | func (w worker) process(job *model.MessageJob) { 14 | var ( 15 | message = job.Message 16 | provider = w.sender.Router.NotFoundProvider 17 | ) 18 | 19 | if message.Provider != nil { 20 | // Send message with specific provider 21 | if p := w.sender.Router.GetProvider(*message.Provider); p != nil { 22 | provider = p 23 | } 24 | } else { 25 | if match, ok := w.sender.Router.Match(message.To); ok { 26 | if message.From == "" && match.From != "" { 27 | message.From = match.From 28 | } 29 | route := match.Name 30 | message.Route = &route 31 | provider = match.GetProvider() 32 | } 33 | } 34 | 35 | p := provider.Name() 36 | message.Provider = &p 37 | 38 | log1 := log.WithFields(log.Fields{ 39 | "message_id": message.ID, 40 | "worker_id": w.id, 41 | }) 42 | log1.WithField("message", message).Debug("worker process") 43 | 44 | // Save the send record to db 45 | rch := w.sender.store.Message().Save(&message) 46 | 47 | message.HandleStep(model.NewMessageStepSending()) 48 | message.HandleStep(provider.Send(message)) 49 | 50 | switch message.Status { 51 | case model.StatusSent, model.StatusDelivered: 52 | log1.Debug("successfully sent the message to the carrier") 53 | default: 54 | log1.WithField("message", message).Error("unable to send the message to the carrier") 55 | } 56 | 57 | if job.Result != nil { 58 | job.Result <- message 59 | } 60 | 61 | if r := <-rch; r.Err != nil { 62 | log1.Errorf("store save error: %v", r.Err) 63 | return 64 | } 65 | if r := <-w.sender.store.Message().Update(&message); r.Err != nil { 66 | log1.Errorf("store update error: %v", r.Err) 67 | } 68 | } 69 | 70 | func (w worker) receipt(receipt model.MessageReceipt) { 71 | log1 := log.WithFields(log.Fields{ 72 | "worker_id": w.id, 73 | "original_message_id": receipt.ProviderMessageID, 74 | }) 75 | log1.WithField("receipt", receipt).Info("handle the message receipt") 76 | 77 | r := <-w.sender.store.Message().GetByProviderAndMessageID(receipt.Provider, receipt.ProviderMessageID) 78 | if r.Err != nil { 79 | log1.Errorf("receipt update error: message not found. %v", r.Err) 80 | return 81 | } 82 | 83 | message := r.Data.(*model.Message) 84 | message.HandleStep(receipt) 85 | 86 | if r := <-w.sender.store.Message().Update(message); r.Err != nil { 87 | log1.Errorf("receipt update error: %v", r.Err) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /webroot/src/components/sms/DetailsPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import SyntaxHighlighter from 'react-syntax-highlighter' 4 | import { agate } from 'react-syntax-highlighter/dist/styles' 5 | 6 | import MessageModel from '../../models/MessageModel' 7 | import api from '../../stores/API' 8 | 9 | @observer 10 | export default class DetailsPage extends Component { 11 | message = new MessageModel() 12 | 13 | constructor (props) { 14 | super(props) 15 | this.fetch = this.fetch.bind(this) 16 | } 17 | 18 | componentDidMount () { 19 | this.fetch() 20 | } 21 | 22 | fetch () { 23 | api.getMessagesByIds(this.props.params.messageId, (json) => { 24 | if (json.data.length) { 25 | this.message.fromJS(json.data[0]) 26 | } 27 | }) 28 | } 29 | 30 | render () { 31 | const json = this.message.json ? JSON.stringify(this.message.json, null, 4) : '' 32 | 33 | return ( 34 |
35 |

Message details

36 | 37 |

Properties

38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
Message ID{this.message.id}Route{this.message.route}
From{this.message.form}Provider{this.message.provider}
To{this.message.to}
Body 60 |
{this.message.body}
61 |
Status{this.message.status}
Created Time{this.message.created_time}
Provider Message ID{this.message.original_message_id}
77 | 78 |

JSON

79 | {json} 84 |
85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /webroot/src/components/sms/SMSList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { Link } from 'react-router' 4 | import { Table, TableBody, TableHeader, TableHeaderColumn, TableRow, TableRowColumn } from 'material-ui/Table' 5 | import RaisedButton from 'material-ui/RaisedButton' 6 | 7 | @inject('routing') 8 | @observer 9 | export default class SMSList extends Component { 10 | constructor (props) { 11 | super(props) 12 | this.store = this.props.store 13 | this.push = this.props.routing.push 14 | } 15 | 16 | pagingPrev = () => { 17 | const since = this.store.since 18 | this.push('/console/sms' + since.substr(since.indexOf('?'))) 19 | } 20 | 21 | pagingNext = () => { 22 | const until = this.store.until 23 | this.push('/console/sms' + until.substr(until.indexOf('?'))) 24 | } 25 | 26 | render () { 27 | return ( 28 |
29 | 30 | 31 | 32 | MESSAGE ID 33 | TO 34 | ROUTE 35 | STATUS 36 | DATE 37 | 38 | 39 | 40 | {(this.store.messages.length === 0) 41 | ? ( 42 | 43 | No data 44 | 45 | ) 46 | : this.store.messages.map((message) => ( 47 | 48 | 49 | {message.id} 50 | 51 | {message.to} 52 | {message.route} 53 | {message.status} 54 | {message.created_time} 55 | 56 | ))} 57 | 58 |
59 | 60 |
61 | {this.store.since && } 62 | {this.store.until && } 63 |
64 |
65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /smsender/model/message_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "reflect" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestNewMessageRecord(t *testing.T) { 12 | dummy := "dummy" 13 | providerMessageID := "b288anp82b37873aj510" 14 | ct, _ := time.Parse(time.RFC3339, "2017-01-01T00:00:03.1415926+08:00") 15 | 16 | j := new(bytes.Buffer) 17 | _ = json.Compact(j, []byte(` 18 | { 19 | "id":"b288anp82b37873aj510", 20 | "to":"+886987654321", 21 | "from":"+1234567890", 22 | "body":"Happy New Year 2017", 23 | "async":false, 24 | "route":"dummy", 25 | "provider":"dummy", 26 | "provider_message_id":"b288anp82b37873aj510", 27 | "steps":[ 28 | { 29 | "stage":"platform", 30 | "data":null, 31 | "status":"accepted", 32 | "created_time":"2017-01-01T00:00:03.1415926+08:00" 33 | }, 34 | { 35 | "stage":"queue", 36 | "data":null, 37 | "status":"sending", 38 | "created_time":"2017-01-01T00:00:03.1415926+08:00" 39 | }, 40 | { 41 | "stage":"queue.response", 42 | "data":null, 43 | "status":"sent", 44 | "created_time":"2017-01-01T00:00:03.1415926+08:00" 45 | }, 46 | { 47 | "stage":"carrier.receipt", 48 | "data":null, 49 | "status":"delivered", 50 | "created_time":"2017-01-01T00:00:03.1415926+08:00" 51 | } 52 | ], 53 | "status":"delivered", 54 | "created_time":"2017-01-01T00:00:03.1415926+08:00", 55 | "updated_time":"2017-01-01T00:00:03.1415926+08:00" 56 | }`)) 57 | 58 | message := NewMessage("+886987654321", "+1234567890", "Happy New Year 2017", false) 59 | message.ID = "b288anp82b37873aj510" 60 | message.Route = &dummy 61 | message.Provider = &dummy 62 | message.CreatedTime = ct 63 | message.SetSteps([]MessageStep{ 64 | { 65 | Stage: StagePlatform, 66 | Status: StatusAccepted, 67 | Data: JSON{}, 68 | CreatedTime: ct, 69 | }, 70 | }) 71 | 72 | step1 := NewMessageStepSending() 73 | step1.CreatedTime = ct 74 | message.HandleStep(step1) 75 | 76 | step2 := NewMessageResponse(StatusSent, nil, &providerMessageID) 77 | step2.CreatedTime = ct 78 | message.HandleStep(step2) 79 | 80 | step3 := NewMessageReceipt(providerMessageID, dummy, StatusDelivered, nil) 81 | step3.CreatedTime = ct 82 | message.HandleStep(step3) 83 | 84 | record, err := json.Marshal(message) 85 | if err != nil { 86 | t.Error("message marshal error:", err.Error()) 87 | } 88 | if !reflect.DeepEqual(record, j.Bytes()) { 89 | t.Errorf("NewMessage returned %s, want %s", record, j) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /smsender/model/status_code.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | ) 9 | 10 | type StatusCode int 11 | 12 | func (c StatusCode) Value() (driver.Value, error) { 13 | return []byte(c.String()), nil 14 | } 15 | 16 | func (c *StatusCode) Scan(src interface{}) error { 17 | switch t := src.(type) { 18 | case []byte: 19 | code, err := statusStringToCode(string(t)) 20 | if err != nil { 21 | return err 22 | } 23 | *c = code 24 | case nil: 25 | *c = StatusInit 26 | default: 27 | return errors.New("Incompatible type for StatusCode") 28 | } 29 | return nil 30 | } 31 | 32 | func (c StatusCode) String() string { 33 | return statusCodeMap[c] 34 | } 35 | 36 | func (c StatusCode) MarshalJSON() ([]byte, error) { 37 | return json.Marshal(c.String()) 38 | } 39 | 40 | func (c *StatusCode) UnmarshalJSON(data []byte) error { 41 | if c == nil { 42 | return errors.New("StatusCode: UnmarshalJSON on nil pointer") 43 | } 44 | var code string 45 | err := json.Unmarshal(data, &code) 46 | if err != nil { 47 | return err 48 | } 49 | statusCode, err := statusStringToCode(code) 50 | if err != nil { 51 | return err 52 | } 53 | *c = statusCode 54 | return nil 55 | } 56 | 57 | func statusStringToCode(status string) (StatusCode, error) { 58 | for k, v := range statusCodeMap { 59 | if v == status { 60 | return k, nil 61 | } 62 | } 63 | return 0, fmt.Errorf("StatusCode %s not exists", status) 64 | } 65 | 66 | // Status 67 | const ( 68 | StatusInit StatusCode = iota // Default status, This should not be exported to client 69 | StatusAccepted // Received your API request to send a message 70 | StatusQueued // The message is queued to be sent out 71 | StatusSending // The message is in the process of dispatching to the upstream carrier 72 | StatusFailed // The message could not be sent to the upstream carrier 73 | StatusSent // The message was successfully accepted by the upstream carrie 74 | StatusUnknown // Received an undocumented status code from the upstream carrier 75 | StatusUndelivered // Received that the message was not delivered from the upstream carrier 76 | StatusDelivered // Received confirmation of message delivery from the upstream carrier 77 | ) 78 | 79 | var statusCodeMap = map[StatusCode]string{ 80 | StatusInit: "init", 81 | StatusAccepted: "accepted", 82 | StatusQueued: "queued", 83 | StatusSending: "sending", 84 | StatusFailed: "failed", 85 | StatusSent: "sent", 86 | StatusUnknown: "unknown", 87 | StatusUndelivered: "undelivered", 88 | StatusDelivered: "delivered", 89 | } 90 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # All Vagrant configuration is done below. The "2" in Vagrant.configure 5 | # configures the configuration version (we support older styles for 6 | # backwards compatibility). Please don't change it unless you know what 7 | # you're doing. 8 | Vagrant.configure("2") do |config| 9 | # The most common configuration options are documented and commented below. 10 | # For a complete reference, please see the online documentation at 11 | # https://docs.vagrantup.com. 12 | 13 | # Every Vagrant development environment requires a box. You can search for 14 | # boxes at https://vagrantcloud.com/search. 15 | config.vm.box = "ubuntu/xenial64" 16 | 17 | # Disable automatic box update checking. If you disable this, then 18 | # boxes will only be checked for updates when the user runs 19 | # `vagrant box outdated`. This is not recommended. 20 | # config.vm.box_check_update = false 21 | 22 | # Create a forwarded port mapping which allows access to a specific port 23 | # within the machine from a port on the host machine. In the example below, 24 | # accessing "localhost:8080" will access port 80 on the guest machine. 25 | # NOTE: This will enable public access to the opened port 26 | # config.vm.network "forwarded_port", guest: 80, host: 8080 27 | 28 | # Create a forwarded port mapping which allows access to a specific port 29 | # within the machine from a port on the host machine and only allow access 30 | # via 127.0.0.1 to disable public access 31 | # config.vm.network "forwarded_port", guest: 80, host: 8080, host_ip: "127.0.0.1" 32 | 33 | # Create a private network, which allows host-only access to the machine 34 | # using a specific IP. 35 | config.vm.network "private_network", ip: "192.168.33.10" 36 | 37 | # Create a public network, which generally matched to bridged network. 38 | # Bridged networks make the machine appear as another physical device on 39 | # your network. 40 | # config.vm.network "public_network" 41 | 42 | # Share an additional folder to the guest VM. The first argument is 43 | # the path on the host to the actual folder. The second argument is 44 | # the path on the guest to mount the folder. And the optional third 45 | # argument is a set of non-required options. 46 | # config.vm.synced_folder "../data", "/vagrant_data" 47 | 48 | # Provider-specific configuration so you can fine-tune various 49 | # backing providers for Vagrant. These expose provider-specific options. 50 | # Example for VirtualBox: 51 | # 52 | config.vm.provider "virtualbox" do |vb| 53 | vb.name = "smsender" 54 | 55 | # Display the VirtualBox GUI when booting the machine 56 | vb.gui = false 57 | 58 | # Customize the amount of memory on the VM: 59 | vb.memory = "1024" 60 | end 61 | # 62 | # View the documentation for the provider you are using for more 63 | # information on available options. 64 | 65 | # Enable provisioning with a shell script. Additional provisioners such as 66 | # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the 67 | # documentation for more information about their specific syntax and use. 68 | config.vm.provision "shell", path: "provision.sh", privileged: false 69 | end -------------------------------------------------------------------------------- /webroot/src/components/router/RouteDialog.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import Dialog from 'material-ui/Dialog' 4 | import TextField from 'material-ui/TextField' 5 | import SelectField from 'material-ui/SelectField' 6 | import MenuItem from 'material-ui/MenuItem' 7 | import Toggle from 'material-ui/Toggle' 8 | import FlatButton from 'material-ui/FlatButton' 9 | 10 | @observer 11 | export default class RouteDialog extends Component { 12 | constructor (props) { 13 | super(props) 14 | this.store = props.store 15 | this.route = props.route 16 | this.updateProperty = this.updateProperty.bind(this) 17 | this.updateProvider = this.updateProvider.bind(this) 18 | this.cancel = this.cancel.bind(this) 19 | this.submit = this.submit.bind(this) 20 | } 21 | 22 | updateProperty (event, value) { 23 | this.route.set(event.target.name, value) 24 | } 25 | 26 | updateProvider (event, index, value) { 27 | this.route.set('provider', value) 28 | } 29 | 30 | cancel () { 31 | this.props.closeRouteDialog() 32 | } 33 | 34 | submit () { 35 | if (this.props.isNew) { 36 | this.store.create(this.route) 37 | } else { 38 | this.store.update(this.route) 39 | } 40 | this.props.closeRouteDialog() 41 | } 42 | 43 | render () { 44 | const actions = [ 45 | , 49 | 54 | ] 55 | 56 | return ( 57 | 63 | 70 |
71 | 77 |
78 | 83 | {this.store.providers.map((provider, i) => ( 84 | 89 | ))} 90 | 91 |
92 | 98 |
99 |
100 | 106 |
107 | ) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /webroot/src/components/sms/SendPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import { action, observable } from 'mobx' 4 | import Paper from 'material-ui/Paper' 5 | import TextField from 'material-ui/TextField' 6 | import RaisedButton from 'material-ui/RaisedButton' 7 | import SyntaxHighlighter from 'react-syntax-highlighter' 8 | import { agate } from 'react-syntax-highlighter/dist/styles' 9 | 10 | import api from '../../stores/API' 11 | 12 | @observer 13 | export default class SendPage extends Component { 14 | @observable message = { 15 | to: '', 16 | from: '', 17 | body: '' 18 | } 19 | @observable response = 'null' 20 | 21 | constructor (props) { 22 | super(props) 23 | this.updateProperty = this.updateProperty.bind(this) 24 | this.post = this.post.bind(this) 25 | this.setResponse = this.setResponse.bind(this) 26 | this.reset = this.reset.bind(this) 27 | } 28 | 29 | componentDidMount () { 30 | this.reset() 31 | } 32 | 33 | @action updateProperty (event, value) { 34 | this.message[event.target.name] = value 35 | } 36 | 37 | @action setResponse (text) { 38 | this.response = text 39 | } 40 | 41 | @action reset () { 42 | this.message.to = '' 43 | this.message.from = '' 44 | this.message.body = '' 45 | this.response = 'null' 46 | } 47 | 48 | post () { 49 | api.postMessage(this.message.to, this.message.from, this.message.body, (json) => { 50 | if (json) { 51 | this.setResponse(JSON.stringify(json, null, 4)) 52 | } 53 | }) 54 | } 55 | 56 | render () { 57 | return ( 58 |
59 |

Send an SMS

60 | 61 |

Request

62 | 63 | 64 | 71 |
72 | 79 |
80 | 90 |
91 |
92 | 97 |
98 |
99 | 100 |

Response

101 | 102 |
103 | {this.response} 108 |
109 |
110 | ) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /smsender/providers/twilio/provider.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | twilio "github.com/carlosdp/twiliogo" 8 | "github.com/minchao/smsender/smsender/model" 9 | "github.com/minchao/smsender/smsender/plugin" 10 | log "github.com/sirupsen/logrus" 11 | config "github.com/spf13/viper" 12 | ) 13 | 14 | const name = "twilio" 15 | 16 | func init() { 17 | plugin.RegisterProvider(name, Plugin) 18 | } 19 | 20 | func Plugin(c *config.Viper) (model.Provider, error) { 21 | return Config{ 22 | Sid: c.GetString("sid"), 23 | Token: c.GetString("token"), 24 | EnableWebhook: c.GetBool("webhook.enable"), 25 | SiteURL: config.GetString("http.siteURL"), 26 | }.New(name) 27 | } 28 | 29 | type Provider struct { 30 | name string 31 | client *twilio.TwilioClient 32 | enableWebhook bool 33 | siteURL string 34 | webhookPath string 35 | } 36 | 37 | type Config struct { 38 | Sid string 39 | Token string 40 | EnableWebhook bool 41 | SiteURL string 42 | } 43 | 44 | // New creates Twilio Provider. 45 | func (c Config) New(name string) (*Provider, error) { 46 | provider := &Provider{ 47 | name: name, 48 | client: twilio.NewClient(c.Sid, c.Token), 49 | } 50 | if c.EnableWebhook { 51 | if c.SiteURL == "" { 52 | return nil, errors.New("Could not create the twilio provider: SiteURL cannot be empty") 53 | } 54 | provider.enableWebhook = true 55 | provider.siteURL = c.SiteURL 56 | provider.webhookPath = "/webhooks/" + name 57 | } 58 | return provider, nil 59 | } 60 | 61 | func (b Provider) Name() string { 62 | return b.name 63 | } 64 | 65 | func (b Provider) Send(message model.Message) *model.MessageResponse { 66 | optionals := []twilio.Optional{twilio.Body(message.Body)} 67 | if b.enableWebhook { 68 | optionals = append(optionals, twilio.StatusCallback(b.siteURL+b.webhookPath)) 69 | } 70 | 71 | resp, err := twilio.NewMessage( 72 | b.client, 73 | message.From, 74 | message.To, 75 | optionals..., 76 | ) 77 | if err != nil { 78 | return model.NewMessageResponse(model.StatusFailed, err, nil) 79 | } 80 | 81 | return model.NewMessageResponse(convertStatus(resp.Status), resp, &resp.Sid) 82 | } 83 | 84 | type DeliveryReceipt struct { 85 | MessageSid string `json:"MessageSid"` 86 | APIVersion string `json:"ApiVersion"` 87 | From string `json:"From"` 88 | To string `json:"To"` 89 | AccountSid string `json:"AccountSid"` 90 | SmsSid string `json:"SmsSid"` 91 | SmsStatus string `json:"SmsStatus"` 92 | MessageStatus string `json:"MessageStatus"` 93 | } 94 | 95 | // Callback see https://www.twilio.com/docs/guides/sms/how-to-confirm-delivery 96 | func (b Provider) Callback(register func(webhook *model.Webhook), receiptsCh chan<- model.MessageReceipt) { 97 | if !b.enableWebhook { 98 | return 99 | } 100 | 101 | register(&model.Webhook{ 102 | Path: b.webhookPath, 103 | Func: func(w http.ResponseWriter, r *http.Request) { 104 | _ = r.ParseForm() 105 | 106 | receipt := DeliveryReceipt{ 107 | MessageSid: r.Form.Get("MessageSid"), 108 | APIVersion: r.Form.Get("ApiVersion"), 109 | From: r.Form.Get("From"), 110 | To: r.Form.Get("To"), 111 | AccountSid: r.Form.Get("AccountSid"), 112 | SmsSid: r.Form.Get("SmsSid"), 113 | SmsStatus: r.Form.Get("SmsStatus"), 114 | MessageStatus: r.Form.Get("MessageStatus"), 115 | } 116 | if receipt.MessageSid == "" || receipt.SmsStatus == "" { 117 | log.Infof("webhooks '%s' empty request body", b.name) 118 | 119 | w.WriteHeader(http.StatusBadRequest) 120 | return 121 | } 122 | 123 | receiptsCh <- *model.NewMessageReceipt( 124 | receipt.MessageSid, 125 | b.Name(), 126 | convertStatus(receipt.SmsStatus), 127 | receipt) 128 | 129 | w.WriteHeader(http.StatusOK) 130 | }, 131 | Method: "POST", 132 | }) 133 | } 134 | 135 | func convertStatus(rawStatus string) model.StatusCode { 136 | var status model.StatusCode 137 | switch rawStatus { 138 | case "accepted", "queued", "sending", "sent": 139 | status = model.StatusSent 140 | case "delivered": 141 | status = model.StatusDelivered 142 | case "undelivered", "failed": 143 | status = model.StatusUndelivered 144 | default: 145 | status = model.StatusUnknown 146 | } 147 | return status 148 | } 149 | -------------------------------------------------------------------------------- /smsender/model/message.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/xid" 7 | ) 8 | 9 | const ( 10 | StagePlatform = "platform" 11 | StageQueue = "queue" 12 | StageQueueResponse = "queue.response" 13 | StageCarrier = "carrier" 14 | StageCarrierReceipt = "carrier.receipt" 15 | ) 16 | 17 | type Message struct { 18 | ID string `json:"id"` // Message ID 19 | To string `json:"to" db:"toNumber"` // The destination phone number (E.164 format) 20 | From string `json:"from" db:"fromName"` // Sender ID (phone number or alphanumeric) 21 | Body string `json:"body"` // The text of the message 22 | Async bool `json:"async"` // Enable a background sending mode that is optimized for bulk sending 23 | Route *string `json:"route"` 24 | Provider *string `json:"provider"` 25 | ProviderMessageID *string `json:"provider_message_id" db:"providerMessageId"` 26 | Steps JSON `json:"steps"` 27 | Status StatusCode `json:"status"` 28 | CreatedTime time.Time `json:"created_time" db:"createdTime"` 29 | UpdatedTime *time.Time `json:"updated_time" db:"updatedTime"` 30 | } 31 | 32 | func NewMessage(to, from, body string, async bool) *Message { 33 | now := Now() 34 | message := Message{ 35 | ID: xid.New().String(), 36 | To: to, 37 | From: from, 38 | Body: body, 39 | Async: async, 40 | Status: StatusAccepted, 41 | CreatedTime: now, 42 | UpdatedTime: &now, 43 | } 44 | message.AddStep(MessageStep{ 45 | Stage: StagePlatform, 46 | Status: StatusAccepted, 47 | Data: JSON{}, 48 | CreatedTime: now, 49 | }) 50 | return &message 51 | } 52 | 53 | func (m *Message) GetSteps() []MessageStep { 54 | var steps []MessageStep 55 | _ = m.Steps.Unmarshal(&steps) 56 | return steps 57 | } 58 | 59 | func (m *Message) SetSteps(steps []MessageStep) { 60 | m.Steps = MarshalJSON(steps) 61 | } 62 | 63 | func (m *Message) AddStep(step MessageStep) { 64 | steps := m.GetSteps() 65 | steps = append(steps, step) 66 | m.SetSteps(steps) 67 | } 68 | 69 | func (m *Message) HandleStep(wrap MessageStepWrap) { 70 | step := wrap.GetStep() 71 | m.AddStep(step) 72 | m.UpdatedTime = &step.CreatedTime 73 | 74 | switch step.Stage { 75 | case StageQueueResponse: 76 | m.ProviderMessageID = wrap.(*MessageResponse).ProviderMessageID 77 | m.Status = step.Status 78 | case StageCarrierReceipt: 79 | if step.Status > StatusSent && step.Status > m.Status { 80 | m.Status = step.Status 81 | } 82 | } 83 | } 84 | 85 | type MessageJob struct { 86 | Message 87 | Result chan Message 88 | } 89 | 90 | func NewMessageJob(to, from, body string, async bool) *MessageJob { 91 | job := MessageJob{ 92 | Message: *NewMessage(to, from, body, async), 93 | } 94 | if !async { 95 | job.Result = make(chan Message, 1) 96 | } 97 | return &job 98 | } 99 | 100 | type MessageStep struct { 101 | Stage string `json:"stage"` 102 | Data interface{} `json:"data"` 103 | Status StatusCode `json:"status"` 104 | CreatedTime time.Time `json:"created_time" db:"createdTime"` 105 | } 106 | 107 | func (ms MessageStep) GetStep() MessageStep { 108 | return ms 109 | } 110 | 111 | func NewMessageStepSending() *MessageStep { 112 | return &MessageStep{ 113 | Stage: StageQueue, 114 | Data: nil, 115 | Status: StatusSending, 116 | CreatedTime: Now(), 117 | } 118 | } 119 | 120 | type MessageStepWrap interface { 121 | GetStep() MessageStep 122 | } 123 | 124 | type MessageResponse struct { 125 | MessageStep 126 | ProviderMessageID *string 127 | } 128 | 129 | func NewMessageResponse(status StatusCode, response interface{}, providerMessageID *string) *MessageResponse { 130 | return &MessageResponse{ 131 | MessageStep: MessageStep{ 132 | Stage: StageQueueResponse, 133 | Data: response, 134 | Status: status, 135 | CreatedTime: Now(), 136 | }, 137 | ProviderMessageID: providerMessageID, 138 | } 139 | } 140 | 141 | type MessageReceipt struct { 142 | MessageStep 143 | ProviderMessageID string 144 | Provider string 145 | } 146 | 147 | func NewMessageReceipt(providerMessageID, provider string, status StatusCode, receipt interface{}) *MessageReceipt { 148 | return &MessageReceipt{ 149 | MessageStep: MessageStep{ 150 | Stage: StageCarrierReceipt, 151 | Data: receipt, 152 | Status: status, 153 | CreatedTime: Now(), 154 | }, 155 | ProviderMessageID: providerMessageID, 156 | Provider: provider, 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /smsender/providers/nexmo/provider.go: -------------------------------------------------------------------------------- 1 | package nexmo 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/minchao/smsender/smsender/model" 7 | "github.com/minchao/smsender/smsender/plugin" 8 | "github.com/minchao/smsender/smsender/utils" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/viper" 11 | "gopkg.in/njern/gonexmo.v1" 12 | ) 13 | 14 | const name = "nexmo" 15 | 16 | func init() { 17 | plugin.RegisterProvider(name, Plugin) 18 | } 19 | 20 | func Plugin(config *viper.Viper) (model.Provider, error) { 21 | return Config{ 22 | Key: config.GetString("key"), 23 | Secret: config.GetString("secret"), 24 | EnableWebhook: config.GetBool("webhook.enable"), 25 | }.New(name) 26 | } 27 | 28 | type Provider struct { 29 | name string 30 | client *nexmo.Client 31 | enableWebhook bool 32 | webhookPath string 33 | } 34 | 35 | type Config struct { 36 | Key string 37 | Secret string 38 | EnableWebhook bool 39 | } 40 | 41 | // New creates Nexmo Provider. 42 | func (c Config) New(name string) (*Provider, error) { 43 | client, err := nexmo.NewClientFromAPI(c.Key, c.Secret) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return &Provider{ 48 | 49 | name: name, 50 | client: client, 51 | enableWebhook: c.EnableWebhook, 52 | webhookPath: "/webhooks/" + name, 53 | }, nil 54 | } 55 | 56 | func (b Provider) Name() string { 57 | return b.name 58 | } 59 | 60 | func (b Provider) Send(message model.Message) *model.MessageResponse { 61 | sms := &nexmo.SMSMessage{ 62 | From: message.From, 63 | To: message.To, 64 | Type: nexmo.Unicode, 65 | Text: message.Body, 66 | } 67 | 68 | resp, err := b.client.SMS.Send(sms) 69 | if err != nil { 70 | return model.NewMessageResponse(model.StatusFailed, model.ProviderError{Error: err.Error()}, nil) 71 | } 72 | 73 | var status model.StatusCode 74 | var providerMessageID *string 75 | if resp.MessageCount > 0 { 76 | respMsg := resp.Messages[0] 77 | 78 | status = convertStatus(respMsg.Status.String()) 79 | providerMessageID = &respMsg.MessageID 80 | } else { 81 | status = model.StatusFailed 82 | } 83 | return model.NewMessageResponse(status, resp, providerMessageID) 84 | } 85 | 86 | type DeliveryReceipt struct { 87 | Msisdn string `json:"msisdn"` 88 | To string `json:"to"` 89 | NetworkCode string `json:"network-code"` 90 | MessageID string `json:"messageId"` 91 | Price string `json:"price"` 92 | Status string `json:"status"` 93 | Scts string `json:"scts"` 94 | ErrCode string `json:"err-code"` 95 | MessageTimestamp string `json:"message-timestamp"` 96 | } 97 | 98 | // Callback see https://docs.nexmo.com/messaging/sms-api/api-reference#delivery_receipt 99 | func (b Provider) Callback(register func(webhook *model.Webhook), receiptsCh chan<- model.MessageReceipt) { 100 | if !b.enableWebhook { 101 | return 102 | } 103 | 104 | register(&model.Webhook{ 105 | Path: b.webhookPath, 106 | Func: func(w http.ResponseWriter, r *http.Request) { 107 | var receipt DeliveryReceipt 108 | err := utils.GetInput(r.Body, &receipt, nil) 109 | if err != nil { 110 | log.Errorf("webhooks '%s' json unmarshal error: %+v", b.name, receipt) 111 | 112 | w.WriteHeader(http.StatusBadRequest) 113 | return 114 | } 115 | if receipt.MessageID == "" || receipt.Status == "" { 116 | log.Infof("webhooks '%s' empty request body", b.name) 117 | 118 | // When you set the callback URL for delivery receipt, 119 | // Nexmo will send several requests to make sure that webhook was okay (status code 200). 120 | w.WriteHeader(http.StatusOK) 121 | return 122 | } 123 | 124 | receiptsCh <- *model.NewMessageReceipt( 125 | receipt.MessageID, 126 | b.Name(), 127 | convertDeliveryReceiptStatus(receipt.Status), 128 | receipt) 129 | 130 | w.WriteHeader(http.StatusOK) 131 | }, 132 | Method: "POST", 133 | }) 134 | } 135 | 136 | func convertStatus(rawStatus string) model.StatusCode { 137 | var status model.StatusCode 138 | switch rawStatus { 139 | case nexmo.ResponseSuccess.String(): 140 | status = model.StatusSent 141 | default: 142 | status = model.StatusFailed 143 | } 144 | return status 145 | } 146 | 147 | func convertDeliveryReceiptStatus(rawStatus string) model.StatusCode { 148 | var status model.StatusCode 149 | switch rawStatus { 150 | case "accepted", "buffered": 151 | status = model.StatusSent 152 | case "delivered": 153 | status = model.StatusDelivered 154 | case "failed", "rejected": 155 | status = model.StatusUndelivered 156 | default: 157 | // expired, unknown 158 | status = model.StatusUnknown 159 | } 160 | return status 161 | } 162 | -------------------------------------------------------------------------------- /smsender/smsender.go: -------------------------------------------------------------------------------- 1 | package smsender 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "sync" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/minchao/smsender/smsender/model" 11 | "github.com/minchao/smsender/smsender/plugin" 12 | "github.com/minchao/smsender/smsender/providers/notfound" 13 | "github.com/minchao/smsender/smsender/router" 14 | "github.com/minchao/smsender/smsender/store" 15 | "github.com/minchao/smsender/smsender/utils" 16 | log "github.com/sirupsen/logrus" 17 | config "github.com/spf13/viper" 18 | "github.com/urfave/negroni" 19 | ) 20 | 21 | type Sender struct { 22 | store store.Store 23 | messagesCh chan *model.MessageJob 24 | receiptsCh chan model.MessageReceipt 25 | workerNum int 26 | 27 | Router *router.Router 28 | // HTTP server router 29 | HTTPRouter *mux.Router 30 | siteURL *url.URL 31 | 32 | shutdown bool 33 | shutdownCh chan struct{} 34 | mutex sync.RWMutex 35 | wg sync.WaitGroup 36 | } 37 | 38 | // NewSender creates Sender. 39 | func NewSender() *Sender { 40 | siteURL, err := url.Parse(config.GetString("http.siteURL")) 41 | if err != nil { 42 | log.Fatalln("config siteURL error:", err) 43 | } 44 | 45 | storeName := config.GetString("store.name") 46 | fn, ok := plugin.StoreFactories[storeName] 47 | if !ok { 48 | log.Fatalf("store factory '%s' not found", storeName) 49 | } 50 | s, err := fn(config.Sub(fmt.Sprintf("store.%s", storeName))) 51 | if err != nil { 52 | log.Fatalf("store init failure: %v", err) 53 | } 54 | log.Debugf("Store: %s", storeName) 55 | 56 | sender := &Sender{ 57 | store: s, 58 | messagesCh: make(chan *model.MessageJob, 1000), 59 | receiptsCh: make(chan model.MessageReceipt, 1000), 60 | workerNum: config.GetInt("worker.num"), 61 | Router: router.New(config.GetViper(), s, notfound.New(model.NotFoundProvider)), 62 | HTTPRouter: mux.NewRouter().StrictSlash(true), 63 | siteURL: siteURL, 64 | shutdownCh: make(chan struct{}, 1), 65 | } 66 | 67 | err = sender.Router.Init() 68 | if err != nil { 69 | log.Fatalln("router init failure:", err) 70 | } 71 | 72 | return sender 73 | } 74 | 75 | func (s *Sender) SearchMessages(params map[string]interface{}) ([]*model.Message, error) { 76 | result := <-s.store.Message().Search(params) 77 | if result.Err != nil { 78 | return nil, result.Err 79 | } 80 | return result.Data.([]*model.Message), nil 81 | } 82 | 83 | func (s *Sender) GetMessagesByIds(ids []string) ([]*model.Message, error) { 84 | result := <-s.store.Message().GetByIds(ids) 85 | if result.Err != nil { 86 | return nil, result.Err 87 | } 88 | return result.Data.([]*model.Message), nil 89 | } 90 | 91 | func (s *Sender) GetMessagesChannel() chan *model.MessageJob { 92 | return s.messagesCh 93 | } 94 | 95 | func (s *Sender) GetSiteURL() *url.URL { 96 | return s.siteURL 97 | } 98 | 99 | // Run performs all startup actions. 100 | func (s *Sender) Run() { 101 | s.InitWebhooks() 102 | s.InitWorkers() 103 | go s.RunHTTPServer() 104 | 105 | select {} 106 | } 107 | 108 | // Shutdown sets shutdown flag and stops all workers. 109 | func (s *Sender) Shutdown() { 110 | s.mutex.Lock() 111 | if s.shutdown { 112 | s.mutex.Unlock() 113 | return 114 | } 115 | s.shutdown = true 116 | s.mutex.Unlock() 117 | 118 | s.wg.Add(s.workerNum) 119 | close(s.shutdownCh) 120 | s.wg.Wait() 121 | } 122 | 123 | // IsShutdown returns true if the server is currently shutting down. 124 | func (s *Sender) IsShutdown() bool { 125 | s.mutex.RLock() 126 | defer s.mutex.RUnlock() 127 | return s.shutdown 128 | } 129 | 130 | // InitWebhooks initializes the webhooks. 131 | func (s *Sender) InitWebhooks() { 132 | for _, provider := range s.Router.GetProviders() { 133 | provider.Callback( 134 | func(webhook *model.Webhook) { 135 | s.HTTPRouter.HandleFunc(webhook.Path, webhook.Func).Methods(webhook.Method) 136 | }, 137 | s.receiptsCh) 138 | } 139 | } 140 | 141 | // InitWorkers initializes the message workers. 142 | func (s *Sender) InitWorkers() { 143 | for i := 0; i < s.workerNum; i++ { 144 | w := worker{i, s} 145 | go func(w worker) { 146 | for { 147 | select { 148 | case message := <-s.messagesCh: 149 | w.process(message) 150 | case receipt := <-s.receiptsCh: 151 | w.receipt(receipt) 152 | case <-s.shutdownCh: 153 | s.wg.Done() 154 | return 155 | } 156 | } 157 | }(w) 158 | } 159 | } 160 | 161 | // RunHTTPServer starts the HTTP server. 162 | func (s *Sender) RunHTTPServer() { 163 | if !config.GetBool("http.enable") { 164 | return 165 | } 166 | 167 | n := negroni.New() 168 | n.UseFunc(utils.Logger) 169 | n.UseHandler(s.HTTPRouter) 170 | 171 | addr := config.GetString("http.addr") 172 | if config.GetBool("http.tls") { 173 | log.Infof("Listening for HTTPS on %s", addr) 174 | log.Fatal(http.ListenAndServeTLS(addr, 175 | config.GetString("http.tlsCertFile"), 176 | config.GetString("http.tlsKeyFile"), 177 | n)) 178 | } else { 179 | log.Infof("Listening for HTTP on %s", addr) 180 | log.Fatal(http.ListenAndServe(addr, n)) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /smsender/store/memory/message_store.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "time" 7 | 8 | "github.com/minchao/smsender/smsender/model" 9 | "github.com/minchao/smsender/smsender/store" 10 | ) 11 | 12 | type MessageStore struct { 13 | *Store 14 | messages []*model.Message 15 | sync.RWMutex 16 | } 17 | 18 | func NewMemoryMessageStore(store *Store) store.MessageStore { 19 | return &MessageStore{store, []*model.Message{}, sync.RWMutex{}} 20 | } 21 | 22 | func (s *MessageStore) Get(id string) store.Channel { 23 | storeChannel := make(store.Channel, 1) 24 | 25 | go func() { 26 | result := store.Result{} 27 | 28 | s.Lock() 29 | defer s.Unlock() 30 | 31 | if _, message, err := s.find(id); err != nil { 32 | result.Err = err 33 | } else { 34 | result.Data = message 35 | } 36 | 37 | storeChannel <- result 38 | close(storeChannel) 39 | }() 40 | 41 | return storeChannel 42 | } 43 | 44 | func (s *MessageStore) GetByIds(ids []string) store.Channel { 45 | storeChannel := make(store.Channel, 1) 46 | 47 | go func() { 48 | result := store.Result{} 49 | 50 | s.Lock() 51 | defer s.Unlock() 52 | 53 | var messages []*model.Message 54 | 55 | for _, id := range ids { 56 | if _, message, err := s.find(id); err == nil { 57 | messages = append(messages, message) 58 | } 59 | } 60 | 61 | result.Data = messages 62 | 63 | storeChannel <- result 64 | close(storeChannel) 65 | }() 66 | 67 | return storeChannel 68 | } 69 | 70 | func (s *MessageStore) GetByProviderAndMessageID(provider, providerMessageID string) store.Channel { 71 | storeChannel := make(store.Channel, 1) 72 | 73 | go func() { 74 | result := store.Result{} 75 | 76 | s.Lock() 77 | defer s.Unlock() 78 | 79 | var message *model.Message 80 | for i, m := range s.messages { 81 | if provider == *m.Provider && providerMessageID == *m.ProviderMessageID { 82 | message = s.messages[i] 83 | break 84 | } 85 | } 86 | if message != nil { 87 | result.Data = message 88 | } else { 89 | result.Err = errors.New("message not found") 90 | } 91 | 92 | storeChannel <- result 93 | close(storeChannel) 94 | }() 95 | 96 | return storeChannel 97 | } 98 | 99 | func (s *MessageStore) Search(params map[string]interface{}) store.Channel { 100 | storeChannel := make(store.Channel, 1) 101 | 102 | go func() { 103 | result := store.Result{} 104 | 105 | s.Lock() 106 | defer s.Unlock() 107 | 108 | messages := []*model.Message{} 109 | length := len(s.messages) 110 | since, hasSince := params["since"] // ASC 111 | until, hasUntil := params["until"] // DESC, default 112 | 113 | for i := 0; i < length; i++ { 114 | var message *model.Message 115 | if hasSince { 116 | message = s.messages[i] 117 | 118 | if !message.CreatedTime.After(since.(time.Time)) { 119 | continue 120 | } 121 | } else { 122 | message = s.messages[length-i-1] 123 | 124 | if hasUntil && !message.CreatedTime.Before(until.(time.Time)) { 125 | continue 126 | } 127 | } 128 | 129 | if to, ok := params["to"]; ok { 130 | if message.To != to.(string) { 131 | continue 132 | } 133 | } 134 | if status, ok := params["status"]; ok { 135 | if message.Status.String() != status.(string) { 136 | continue 137 | } 138 | } 139 | 140 | messages = append(messages, message) 141 | 142 | if limit, ok := params["limit"]; ok { 143 | if len(messages) == limit.(int) { 144 | break 145 | } 146 | } 147 | } 148 | 149 | if hasSince { 150 | if num := len(messages); num > 1 { 151 | // Reverse the messages slice 152 | for i, j := 0, num-1; i < j; i, j = i+1, j-1 { 153 | messages[i], messages[j] = messages[j], messages[i] 154 | } 155 | } 156 | } 157 | 158 | result.Data = messages 159 | 160 | storeChannel <- result 161 | close(storeChannel) 162 | }() 163 | 164 | return storeChannel 165 | } 166 | 167 | func (s *MessageStore) Save(message *model.Message) store.Channel { 168 | storeChannel := make(store.Channel, 1) 169 | 170 | go func() { 171 | result := store.Result{} 172 | 173 | s.Lock() 174 | defer s.Unlock() 175 | 176 | s.messages = append(s.messages, message) 177 | result.Data = &s.messages[len(s.messages)-1] 178 | 179 | storeChannel <- result 180 | close(storeChannel) 181 | }() 182 | 183 | return storeChannel 184 | } 185 | 186 | func (s *MessageStore) Update(message *model.Message) store.Channel { 187 | storeChannel := make(store.Channel, 1) 188 | 189 | go func() { 190 | result := store.Result{} 191 | 192 | s.Lock() 193 | defer s.Unlock() 194 | 195 | if _, m, err := s.find(message.ID); err != nil { 196 | result.Err = err 197 | } else { 198 | *m = *message 199 | result.Data = m 200 | } 201 | 202 | storeChannel <- result 203 | close(storeChannel) 204 | }() 205 | 206 | return storeChannel 207 | } 208 | 209 | func (s *MessageStore) find(messageID string) (int64, *model.Message, error) { 210 | for index, message := range s.messages { 211 | if message.ID == messageID { 212 | return int64(index), message, nil 213 | } 214 | } 215 | 216 | return 0, nil, errors.New("message not found") 217 | } 218 | -------------------------------------------------------------------------------- /webroot/src/components/sms/SMSPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { action, observable } from 'mobx' 4 | import { Toolbar, ToolbarGroup } from 'material-ui/Toolbar' 5 | import DropDownMenu from 'material-ui/DropDownMenu' 6 | import MenuItem from 'material-ui/MenuItem' 7 | import TextField from 'material-ui/TextField' 8 | import RaisedButton from 'material-ui/RaisedButton' 9 | 10 | import MessageStore from '../../stores/MessageStore' 11 | import SMSList from './SMSList' 12 | 13 | const status = [ 14 | {text: 'All Status', value: ''}, 15 | {text: 'Accepted', value: 'accepted'}, 16 | {text: 'Queued', value: 'queued'}, 17 | {text: 'Sending', value: 'sending'}, 18 | {text: 'Failed', value: 'failed'}, 19 | {text: 'Sent', value: 'sent'}, 20 | {text: 'Unknown', value: 'unknown'}, 21 | {text: 'Undelivered', value: 'undelivered'}, 22 | {text: 'Delivered', value: 'delivered'} 23 | ] 24 | 25 | @inject('routing') 26 | @observer 27 | export default class SMSPage extends Component { 28 | static defaultProps = { 29 | store: new MessageStore() 30 | } 31 | 32 | @observable form = { 33 | id: '', 34 | to: '', 35 | status: '', 36 | since: '', 37 | until: '', 38 | limit: 20 39 | } 40 | 41 | constructor (props) { 42 | super(props) 43 | this.queryString = null 44 | this.push = this.props.routing.push 45 | this.setForm = this.setForm.bind(this) 46 | this.resetForm = this.resetForm.bind(this) 47 | this.updateFormProperty = this.updateFormProperty.bind(this) 48 | this.updateFormStatus = this.updateFormStatus.bind(this) 49 | } 50 | 51 | componentDidMount () { 52 | this.setForm() 53 | this.fetch() 54 | } 55 | 56 | componentDidUpdate (prevProps) { 57 | const queryString = this.props.routing.location.pathname + this.props.routing.location.search 58 | 59 | if (this.queryString !== queryString) { 60 | this.setForm() 61 | this.fetch() 62 | } 63 | } 64 | 65 | @action setForm () { 66 | this.resetForm() 67 | const query = this.props.routing.location.query 68 | if (query.id) this.form.id = query.id 69 | if (query.to) this.form.to = query.to 70 | if (query.status) this.form.status = query.status 71 | if (query.since) this.form.since = query.since 72 | if (query.until) this.form.until = query.until 73 | if (query.limit) this.form.limit = query.limit 74 | 75 | this.queryString = this.props.routing.location.pathname + this.props.routing.location.search 76 | } 77 | 78 | @action resetForm () { 79 | this.form.id = '' 80 | this.form.to = '' 81 | this.form.status = '' 82 | this.form.since = '' 83 | this.form.until = '' 84 | this.form.limit = 20 85 | } 86 | 87 | @action updateFormProperty (event, value) { 88 | this.form[event.target.name] = value 89 | } 90 | 91 | @action updateFormStatus (event, index) { 92 | this.form.status = status[index].value 93 | } 94 | 95 | fetch = () => { 96 | if (this.form.id) { 97 | this.props.store.find(this.form.id) 98 | } else { 99 | this.props.store.search(this.form.to, this.form.status, this.form.since, this.form.until, this.form.limit) 100 | } 101 | } 102 | 103 | find = () => { 104 | this.push('/console/sms?id=' + this.form.id) 105 | } 106 | 107 | search = () => { 108 | const query = this.props.store.buildQueryString(this.form.to, this.form.status, '', '', this.form.limit) 109 | this.push('/console/sms' + query) 110 | } 111 | 112 | render () { 113 | return ( 114 |
115 |

SMS Delivery Logs

116 | 117 |

Search by message ID

118 | 119 | 120 | 121 | 129 | 130 | 131 | 136 | 137 | 138 | 139 |

Search by recipient phone number

140 | 141 | 142 | 143 | 150 | 151 | 152 | 157 | {status.map((s, i) => ( 158 | 159 | ))} 160 | 161 | 166 | 167 | 168 | 169 | 170 |
171 | ) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /webroot/src/components/router/RouterPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { action, observable } from 'mobx' 4 | import { Toolbar, ToolbarGroup } from 'material-ui/Toolbar' 5 | import { Table, TableBody, TableHeader, TableHeaderColumn, TableRow, TableRowColumn } from 'material-ui/Table' 6 | import RaisedButton from 'material-ui/RaisedButton' 7 | import IconButton from 'material-ui/IconButton' 8 | import { blue500 } from 'material-ui/styles/colors' 9 | import SvgIconKeyboardArrowUp from 'material-ui/svg-icons/hardware/keyboard-arrow-up' 10 | import SvgIconKeyboardArrowDown from 'material-ui/svg-icons/hardware/keyboard-arrow-down' 11 | 12 | import RouteDialog from './RouteDialog' 13 | import RouteModel from '../../models/RouteModel' 14 | import RouteStore from '../../stores/RouteStore' 15 | 16 | const styles = { 17 | reorder: { 18 | textAlign: 'right' 19 | } 20 | } 21 | 22 | @inject('routing') 23 | @observer 24 | export default class RouterPage extends Component { 25 | static defaultProps = { 26 | store: new RouteStore() 27 | } 28 | 29 | @observable selected = [] 30 | @observable isOpen = false 31 | @observable isNew = false 32 | 33 | route = new RouteModel() 34 | 35 | constructor (props) { 36 | super(props) 37 | this.openRouteDialog = this.openRouteDialog.bind(this) 38 | this.closeRouteDialog = this.closeRouteDialog.bind(this) 39 | this.setIsNew = this.setIsNew.bind(this) 40 | this.createRoute = this.createRoute.bind(this) 41 | this.updateRoute = this.updateRoute.bind(this) 42 | this.deleteRoute = this.deleteRoute.bind(this) 43 | this.reorderUp = this.reorderUp.bind(this) 44 | this.reorderDown = this.reorderDown.bind(this) 45 | } 46 | 47 | componentDidMount () { 48 | this.props.store.sync() 49 | } 50 | 51 | @action openRouteDialog () { 52 | this.isOpen = true 53 | } 54 | 55 | @action closeRouteDialog () { 56 | this.isOpen = false 57 | } 58 | 59 | @action setIsNew (isNew) { 60 | this.isNew = isNew 61 | } 62 | 63 | createRoute () { 64 | this.setIsNew(true) 65 | this.route.fromJS({name: '', pattern: '', provider: '', from: '', is_active: false}) 66 | this.openRouteDialog() 67 | } 68 | 69 | updateRoute (e) { 70 | e.preventDefault() 71 | this.setIsNew(false) 72 | this.route.fromJS(this.props.store.getByName(e.target.name)) 73 | this.openRouteDialog() 74 | } 75 | 76 | deleteRoute () { 77 | if (this.selected[0] !== undefined) { 78 | const route = this.props.store.routes[this.selected[0]] 79 | if (route) { 80 | this.props.store.del(route.name) 81 | } 82 | } 83 | } 84 | 85 | reorderUp (index) { 86 | this.props.store.reorder(index, 1, index - 1) 87 | } 88 | 89 | reorderDown (index) { 90 | this.props.store.reorder(index, 1, index + 2) 91 | } 92 | 93 | render () { 94 | const hasRoutes = this.props.store.routes.length !== 0 95 | 96 | return ( 97 |
98 |

Router

99 | 100 |

Manage routes

101 | 102 | 103 | 104 | 105 | 111 | 116 | 117 | 118 | 119 | { this.selected = rows }} 122 | > 123 | 124 | 125 | NAME 126 | PATTERN 127 | PROVIDER 128 | STATUS 129 | REORDER 130 | 131 | 132 | 136 | {(!hasRoutes) 137 | ? ( 138 | 139 | No data 140 | 141 | ) 142 | : this.props.store.routes.map((route, i) => ( 143 | 144 | 145 | 150 | {route.name} 151 | 152 | 153 | {route.pattern} 154 | {route.provider} 155 | {route.is_active ? 'enable' : 'disable'} 156 | 157 | 158 | {i !== 0 && this.reorderUp(i)} />} 159 | 160 | 161 | {i !== this.props.store.routes.length - 1 && 162 | this.reorderDown(i)} />} 163 | 164 | 165 | 166 | 167 | ))} 168 | 169 |
170 | 171 | 178 |
179 | ) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SMSender 2 | 3 | [![Build Status](https://travis-ci.org/minchao/smsender.svg?branch=master)](https://travis-ci.org/minchao/smsender) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/minchao/smsender)](https://goreportcard.com/report/github.com/minchao/smsender) 5 | 6 | A SMS server written in Go (Golang). 7 | 8 | * Support various SMS providers. 9 | * Support routing, uses routes to determine which provider to send SMS. 10 | * Support command line to send a single SMS. 11 | * Support to receive delivery receipts from provider. 12 | * SMS delivery worker. 13 | * SMS delivery records. 14 | * RESTful API. 15 | * Admin Console UI. 16 | 17 | ## Requirements 18 | 19 | * [Go](https://golang.org/) >= 1.11 20 | * MySQL >= 5.7 21 | 22 | ## Installing 23 | 24 | Getting the project: 25 | 26 | ```bash 27 | go get github.com/minchao/smsender 28 | ``` 29 | 30 | Using the [Dep](https://github.com/golang/dep) to install dependency packages: 31 | 32 | ```bash 33 | dep ensure 34 | ``` 35 | 36 | Creating a Configuration file: 37 | 38 | ```bash 39 | cp ./config/config.default.yml ./config.yml 40 | ``` 41 | 42 | Setup the MySQL DSN: 43 | 44 | ```yaml 45 | store: 46 | name: "sql" 47 | sql: 48 | driver: "mysql" 49 | dsn: "user:password@tcp(localhost:3306)/dbname?parseTime=true&loc=Local" 50 | ``` 51 | 52 | Registering providers on the sender server. 53 | 54 | Add the provider key and secret to config.yml: 55 | 56 | ```yaml 57 | providers: 58 | nexmo: 59 | key: "NEXMO_KEY" 60 | secret: "NEXMO_SECRET" 61 | ``` 62 | 63 | Build: 64 | 65 | ```bash 66 | go build -o bin/smsender 67 | ``` 68 | 69 | Run: 70 | 71 | ```bash 72 | ./bin/smsender 73 | ``` 74 | 75 | ## Running smsender server in docker container 76 | 77 | You can use the [docker-compose](https://docs.docker.com/compose/) to launch the preview version of SMSender, It will start the app and db in separate containers: 78 | 79 | ```bash 80 | docker-compose up 81 | ``` 82 | 83 | ## Vagrant development environment 84 | 85 | Start the vagrant machine 86 | 87 | ```bash 88 | vagrant up 89 | ``` 90 | 91 | SSH into the vagrant machine 92 | 93 | ```bash 94 | vagrant ssh 95 | ``` 96 | 97 | Run the smsender server 98 | 99 | ```bash 100 | go run main.go -c config/config.yml 101 | ``` 102 | 103 | ## Providers 104 | 105 | Support providers 106 | 107 | * [AWS SNS (SMS)](https://aws.amazon.com/sns/) 108 | * [Nexmo](https://www.nexmo.com/) 109 | * [Twilio](https://www.twilio.com/) 110 | 111 | Need another provider? Just implement the [Provider](https://github.com/minchao/smsender/blob/master/smsender/model/provider.go) interface. 112 | 113 | ## Routing 114 | 115 | Route can be define a regexp phone number pattern to be matched with provider. 116 | 117 | Example: 118 | 119 | | Name | Regular expression | Provider | Description | 120 | |---------|---------------------|----------|-------------------| 121 | | Dummy | \\+12345678900 | dummy | For testing | 122 | | User1 | \\+886987654321 | aws | For specific user | 123 | | Taiwan | \\+886 | nexmo | | 124 | | USA | \\+1 | twilio | | 125 | | Default | .* | nexmo | Default | 126 | 127 | ## Commands 128 | 129 | ```bash 130 | ./bin/smsender -h 131 | A SMS server written in Go (Golang) 132 | 133 | Usage: 134 | smsender [flags] 135 | smsender [command] 136 | 137 | Available Commands: 138 | help Help about any command 139 | routes List all routes 140 | send Send message 141 | 142 | Flags: 143 | -c, --config string Configuration file path 144 | -d, --debug Enable debug mode 145 | -h, --help help for smsender 146 | 147 | Use "smsender [command] --help" for more information about a command. 148 | ``` 149 | 150 | ### Example of sending a single SMS to one destination 151 | 152 | ```bash 153 | ./bin/smsender send --to +12345678900 --from Gopher --body "Hello, 世界" --provider dummy 154 | ``` 155 | 156 | ## RESTful API 157 | 158 | The API document is written in YAML and found in the [openapi.yaml](openapi.yaml). 159 | You can use the [Swagger Editor](http://editor.swagger.io/) to open the document. 160 | 161 | ### Example of creating a Dummy route 162 | 163 | Request: 164 | 165 | ```bash 166 | curl -X POST http://localhost:8080/api/routes \ 167 | -H "Content-Type: application/json" \ 168 | -d '{"name": "Dummy", "pattern": "\\+12345678900", "provider": "dummy", "is_active", true}' 169 | ``` 170 | 171 | Response format: 172 | 173 | ```json 174 | { 175 | "name": "Dummy", 176 | "pattern": "\\+12345678900", 177 | "provider": "dummy", 178 | "from": "", 179 | "is_active": true 180 | } 181 | ``` 182 | 183 | ### Example of sending a single SMS to one destination 184 | 185 | Request: 186 | 187 | ```bash 188 | curl -X POST http://localhost:8080/api/messages \ 189 | -H "Content-Type: application/json" \ 190 | -d '{"to": ["+12345678900"],"from": "Gopher","body": "Hello, 世界"}' 191 | ``` 192 | 193 | Response format: 194 | 195 | ```json 196 | { 197 | "data": [ 198 | { 199 | "id": "b3oe98ent9k002f6ajp0", 200 | "to": "+12345678900", 201 | "from": "Gopher", 202 | "body": "Hello, 世界", 203 | "async": false, 204 | "route": "Dummy", 205 | "provider": "dummy", 206 | "provider_message_id": "b3oe98ent9k002f6ajp0", 207 | "steps": [ 208 | { 209 | "stage": "platform", 210 | "data": null, 211 | "status": "accepted", 212 | "created_time": "2017-04-14T15:02:57.123202Z" 213 | }, 214 | { 215 | "stage": "queue", 216 | "data": null, 217 | "status": "sending", 218 | "created_time": "2017-04-14T15:02:57.123556Z" 219 | }, 220 | { 221 | "stage": "queue.response", 222 | "data": null, 223 | "status": "delivered", 224 | "created_time": "2017-04-14T15:02:57.123726Z" 225 | } 226 | ], 227 | "status": "delivered", 228 | "created_time": "2017-04-14T15:02:57.123202Z", 229 | "updated_time": "2017-04-14T15:02:57.123726Z" 230 | } 231 | ] 232 | } 233 | ``` 234 | 235 | ## Admin Console UI 236 | 237 | The Console Web UI allows you to manage routes and monitor messages (at http://localhost:8080/console/). 238 | 239 | ![logs screenshot](docs/screenshot/logs.jpg) 240 | 241 | ![router screenshot](docs/screenshot/router.jpg) 242 | 243 | ## License 244 | 245 | See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT). 246 | -------------------------------------------------------------------------------- /smsender/store/sql/message_store.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/minchao/smsender/smsender/model" 6 | "github.com/minchao/smsender/smsender/store" 7 | ) 8 | 9 | const SQLMessageTable = ` 10 | CREATE TABLE IF NOT EXISTS message ( 11 | id varchar(40) COLLATE utf8_unicode_ci NOT NULL, 12 | toNumber varchar(20) COLLATE utf8_unicode_ci NOT NULL, 13 | fromName varchar(20) COLLATE utf8_unicode_ci NOT NULL, 14 | body text COLLATE utf8_unicode_ci NOT NULL, 15 | async tinyint(1) NOT NULL DEFAULT '0', 16 | route varchar(32) COLLATE utf8_unicode_ci DEFAULT NULL, 17 | provider varchar(32) COLLATE utf8_unicode_ci DEFAULT NULL, 18 | providerMessageId varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL, 19 | steps json DEFAULT NULL, 20 | status varchar(20) COLLATE utf8_unicode_ci NOT NULL, 21 | createdTime datetime(6) NOT NULL, 22 | updatedTime datetime(6) DEFAULT NULL, 23 | PRIMARY KEY (id), 24 | KEY providerMessageId (provider, providerMessageId) 25 | ) DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci` 26 | 27 | type MessageStore struct { 28 | *Store 29 | } 30 | 31 | func NewSQLMessageStore(sqlStore *Store) store.MessageStore { 32 | ms := &MessageStore{sqlStore} 33 | 34 | ms.db.MustExec(SQLMessageTable) 35 | 36 | return ms 37 | } 38 | 39 | func (ms *MessageStore) Get(id string) store.Channel { 40 | storeChannel := make(store.Channel, 1) 41 | 42 | go func() { 43 | result := store.Result{} 44 | 45 | var message model.Message 46 | if err := ms.db.Get(&message, `SELECT * FROM message WHERE id = ?`, id); err != nil { 47 | result.Err = err 48 | } else { 49 | result.Data = &message 50 | } 51 | 52 | storeChannel <- result 53 | close(storeChannel) 54 | }() 55 | 56 | return storeChannel 57 | } 58 | 59 | func (ms *MessageStore) GetByIds(ids []string) store.Channel { 60 | storeChannel := make(store.Channel, 1) 61 | 62 | go func() { 63 | result := store.Result{} 64 | 65 | query, args, err := sqlx.In("SELECT * FROM message WHERE id IN (?)", ids) 66 | if err != nil { 67 | result.Err = err 68 | storeChannel <- result 69 | close(storeChannel) 70 | return 71 | } 72 | query = ms.db.Rebind(query) 73 | 74 | var messages []*model.Message 75 | if err := ms.db.Select(&messages, query, args...); err != nil { 76 | result.Err = err 77 | } else { 78 | result.Data = messages 79 | } 80 | 81 | storeChannel <- result 82 | close(storeChannel) 83 | }() 84 | 85 | return storeChannel 86 | } 87 | 88 | func (ms *MessageStore) GetByProviderAndMessageID(provider, providerMessageID string) store.Channel { 89 | storeChannel := make(store.Channel, 1) 90 | 91 | go func() { 92 | result := store.Result{} 93 | 94 | var message model.Message 95 | if err := ms.db.Get(&message, `SELECT * FROM message 96 | WHERE provider = ? AND providerMessageId = ?`, provider, providerMessageID); err != nil { 97 | result.Err = err 98 | } else { 99 | result.Data = &message 100 | } 101 | 102 | storeChannel <- result 103 | close(storeChannel) 104 | }() 105 | 106 | return storeChannel 107 | } 108 | 109 | func (ms *MessageStore) Search(params map[string]interface{}) store.Channel { 110 | storeChannel := make(store.Channel, 1) 111 | 112 | go func() { 113 | result := store.Result{} 114 | 115 | query := "SELECT * FROM message" 116 | where := "" 117 | order := "DESC" 118 | args := []interface{}{} 119 | 120 | if since, ok := params["since"]; ok { 121 | where += " createdTime > ?" 122 | order = "ASC" 123 | args = append(args, since) 124 | } 125 | if until, ok := params["until"]; ok { 126 | where += sqlAndWhere(where) 127 | where += " createdTime < ?" 128 | args = append(args, until) 129 | } 130 | if to, ok := params["to"]; ok { 131 | where += sqlAndWhere(where) 132 | where += " toNumber = ?" 133 | args = append(args, to) 134 | } 135 | if status, ok := params["status"]; ok { 136 | where += sqlAndWhere(where) 137 | where += " status = ?" 138 | args = append(args, status) 139 | } 140 | if where != "" { 141 | query += " WHERE" + where 142 | } 143 | 144 | query += " ORDER BY createdTime " + order 145 | 146 | if limit, ok := params["limit"]; ok { 147 | query += " LIMIT ?" 148 | args = append(args, limit) 149 | } 150 | 151 | var messages []*model.Message 152 | if err := ms.db.Select(&messages, query, args...); err != nil { 153 | result.Err = err 154 | } else { 155 | length := len(messages) 156 | if order == "ASC" && length > 1 { 157 | // Reverse the messages slice 158 | for i, j := 0, length-1; i < j; i, j = i+1, j-1 { 159 | messages[i], messages[j] = messages[j], messages[i] 160 | } 161 | } 162 | 163 | result.Data = messages 164 | } 165 | 166 | storeChannel <- result 167 | close(storeChannel) 168 | }() 169 | 170 | return storeChannel 171 | } 172 | 173 | func (ms *MessageStore) Save(message *model.Message) store.Channel { 174 | storeChannel := make(store.Channel, 1) 175 | 176 | go func() { 177 | result := store.Result{} 178 | 179 | _, err := ms.db.Exec(`INSERT INTO message 180 | ( 181 | id, 182 | toNumber, 183 | fromName, 184 | body, 185 | async, 186 | route, 187 | provider, 188 | providerMessageId, 189 | steps, 190 | status, 191 | createdTime, 192 | updatedTime 193 | ) 194 | VALUES 195 | (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 196 | message.ID, 197 | message.To, 198 | message.From, 199 | message.Body, 200 | message.Async, 201 | message.Route, 202 | message.Provider, 203 | message.ProviderMessageID, 204 | message.Steps, 205 | message.Status, 206 | message.CreatedTime, 207 | message.UpdatedTime, 208 | ) 209 | if err != nil { 210 | result.Err = err 211 | } else { 212 | result.Data = message 213 | } 214 | 215 | storeChannel <- result 216 | close(storeChannel) 217 | }() 218 | 219 | return storeChannel 220 | } 221 | 222 | func (ms *MessageStore) Update(message *model.Message) store.Channel { 223 | storeChannel := make(store.Channel, 1) 224 | 225 | go func() { 226 | result := store.Result{} 227 | 228 | _, err := ms.db.Exec(`UPDATE message 229 | SET 230 | toNumber = ?, 231 | fromName = ?, 232 | body = ?, 233 | async = ?, 234 | route = ?, 235 | provider = ?, 236 | providerMessageId = ?, 237 | steps = ?, 238 | status = ?, 239 | createdTime = ?, 240 | updatedTime = ? 241 | WHERE id = ?`, 242 | message.To, 243 | message.From, 244 | message.Body, 245 | message.Async, 246 | message.Route, 247 | message.Provider, 248 | message.ProviderMessageID, 249 | message.Steps, 250 | message.Status, 251 | message.CreatedTime, 252 | message.UpdatedTime, 253 | message.ID, 254 | ) 255 | if err != nil { 256 | result.Err = err 257 | } else { 258 | result.Data = message 259 | } 260 | 261 | storeChannel <- result 262 | close(storeChannel) 263 | }() 264 | 265 | return storeChannel 266 | } 267 | -------------------------------------------------------------------------------- /smsender/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/minchao/smsender/smsender/model" 9 | "github.com/minchao/smsender/smsender/plugin" 10 | "github.com/minchao/smsender/smsender/store" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | // Router registers routes to be matched and dispatches a provider. 15 | type Router struct { 16 | config *viper.Viper 17 | store store.Store 18 | providers map[string]model.Provider 19 | pMutex sync.RWMutex 20 | routes []*model.Route 21 | rMutex sync.RWMutex 22 | 23 | // Configurable Provider to be used when no route matches. 24 | NotFoundProvider model.Provider 25 | } 26 | 27 | // New creates a new instance of the Router. 28 | func New(config *viper.Viper, store store.Store, notFoundProvider model.Provider) *Router { 29 | return &Router{ 30 | config: config, 31 | store: store, 32 | routes: make([]*model.Route, 0), 33 | providers: make(map[string]model.Provider), 34 | NotFoundProvider: notFoundProvider, 35 | } 36 | } 37 | 38 | // Init initializes the router. 39 | func (r *Router) Init() error { 40 | for name := range r.config.GetStringMap("providers") { 41 | fn, ok := plugin.ProviderFactories[name] 42 | if ok { 43 | provider, err := fn(r.config.Sub(fmt.Sprintf("providers.%s", name))) 44 | if err != nil { 45 | return fmt.Errorf("Provider %s not registered", name) 46 | } 47 | r.AddProvider(provider) 48 | } 49 | } 50 | return r.LoadFromDB() 51 | } 52 | 53 | func (r *Router) GetProviders() map[string]model.Provider { 54 | r.pMutex.RLock() 55 | defer r.pMutex.RUnlock() 56 | return r.providers 57 | } 58 | 59 | func (r *Router) GetProvider(name string) model.Provider { 60 | r.pMutex.RLock() 61 | defer r.pMutex.RUnlock() 62 | if provider, exists := r.providers[name]; exists { 63 | return provider 64 | } 65 | return nil 66 | } 67 | 68 | func (r *Router) AddProvider(provider model.Provider) { 69 | r.pMutex.Lock() 70 | defer r.pMutex.Unlock() 71 | if _, exists := r.providers[provider.Name()]; exists { 72 | panic(fmt.Sprintf("provider '%s' already added", provider.Name())) 73 | } 74 | r.providers[provider.Name()] = provider 75 | } 76 | 77 | func (r *Router) GetAll() []*model.Route { 78 | r.rMutex.RLock() 79 | defer r.rMutex.RUnlock() 80 | return r.routes 81 | } 82 | 83 | func (r *Router) get(name string) (int, *model.Route) { 84 | for i, route := range r.routes { 85 | if route.Name == name { 86 | return i, route 87 | } 88 | } 89 | return 0, nil 90 | } 91 | 92 | // Get returns route by specify name. 93 | func (r *Router) Get(name string) *model.Route { 94 | r.rMutex.RLock() 95 | defer r.rMutex.RUnlock() 96 | _, route := r.get(name) 97 | return route 98 | } 99 | 100 | // Add adds new route to the beginning of routes slice. 101 | func (r *Router) Add(route *model.Route) error { 102 | r.rMutex.Lock() 103 | defer r.rMutex.Unlock() 104 | r.routes = append([]*model.Route{route}, r.routes...) 105 | return r.saveToDB() 106 | } 107 | 108 | // AddWith adds new route with parameters. 109 | func (r *Router) AddWith(name, pattern, providerName, from string, isActive bool) error { 110 | route := r.Get(name) 111 | if route != nil { 112 | return errors.New("route already exists") 113 | } 114 | provider := r.GetProvider(providerName) 115 | if provider == nil { 116 | return errors.New("provider not found") 117 | } 118 | return r.Add(model.NewRoute(name, pattern, provider, isActive).SetFrom(from)) 119 | } 120 | 121 | func (r *Router) Set(name, pattern string, provider model.Provider, from string, isActive bool) error { 122 | r.rMutex.Lock() 123 | defer r.rMutex.Unlock() 124 | _, route := r.get(name) 125 | if route == nil { 126 | return errors.New("route not found") 127 | } 128 | route.SetPattern(pattern) 129 | route.SetProvider(provider) 130 | route.From = from 131 | route.IsActive = isActive 132 | return r.saveToDB() 133 | } 134 | 135 | func (r *Router) SetWith(name, pattern, providerName, from string, isActive bool) error { 136 | provider := r.GetProvider(providerName) 137 | if provider == nil { 138 | return errors.New("provider not found") 139 | } 140 | if err := r.Set(name, pattern, provider, from, isActive); err != nil { 141 | return err 142 | } 143 | return nil 144 | } 145 | 146 | func (r *Router) Remove(name string) error { 147 | r.rMutex.Lock() 148 | defer r.rMutex.Unlock() 149 | idx, route := r.get(name) 150 | if route != nil { 151 | r.routes = append(r.routes[:idx], r.routes[idx+1:]...) 152 | } 153 | return r.saveToDB() 154 | } 155 | 156 | func (r *Router) Reorder(rangeStart, rangeLength, insertBefore int) error { 157 | r.rMutex.Lock() 158 | defer r.rMutex.Unlock() 159 | length := len(r.routes) 160 | if rangeStart < 0 { 161 | return errors.New("invalid rangeStart, it should be >= 0") 162 | } 163 | if rangeStart > (length - 1) { 164 | return errors.New("invalid rangeStart, out of bounds") 165 | } 166 | if rangeLength <= 0 { 167 | return errors.New("invalid rangeLength, it should be > 0") 168 | } 169 | if (rangeStart + rangeLength) > length { 170 | return errors.New("route selected to be reordered are out of bounds") 171 | } 172 | if insertBefore < 0 { 173 | return errors.New("invalid insertBefore, it should be >= 0") 174 | } 175 | if insertBefore > length { 176 | return errors.New("invalid insertBefore, out of bounds") 177 | } 178 | 179 | rangeEnd := rangeStart + rangeLength 180 | if insertBefore >= rangeStart && insertBefore <= rangeEnd { 181 | return nil 182 | } 183 | 184 | var result []*model.Route 185 | 186 | result = append(result, r.routes[:insertBefore]...) 187 | result = append(result, r.routes[rangeStart:rangeEnd]...) 188 | result = append(result, r.routes[insertBefore:]...) 189 | idxToRemove := rangeStart 190 | if insertBefore < rangeStart { 191 | idxToRemove += rangeLength 192 | } 193 | result = append(result[:idxToRemove], result[idxToRemove+rangeLength:]...) 194 | 195 | r.routes = result 196 | return r.saveToDB() 197 | } 198 | 199 | func (r *Router) saveToDB() error { 200 | if result := <-r.store.Route().SaveAll(r.routes); result.Err != nil { 201 | return result.Err 202 | } 203 | return nil 204 | } 205 | 206 | // SaveToDB saves the routes into database. 207 | func (r *Router) SaveToDB() error { 208 | r.rMutex.RLock() 209 | defer r.rMutex.RUnlock() 210 | return r.saveToDB() 211 | } 212 | 213 | // LoadFromDB returns the routes from database. 214 | func (r *Router) LoadFromDB() error { 215 | r.rMutex.Lock() 216 | defer r.rMutex.Unlock() 217 | result := <-r.store.Route().GetAll() 218 | if result.Err != nil { 219 | return result.Err 220 | } 221 | 222 | routes := []*model.Route{} 223 | routeRows := result.Data.([]*model.Route) 224 | for _, row := range routeRows { 225 | if provider := r.GetProvider(row.Provider); provider != nil { 226 | routes = append(routes, model.NewRoute(row.Name, row.Pattern, provider, row.IsActive).SetFrom(row.From)) 227 | } 228 | } 229 | 230 | r.routes = routes 231 | 232 | return nil 233 | } 234 | 235 | func (r *Router) Match(phone string) (*model.Route, bool) { 236 | routes := r.GetAll() 237 | for _, r := range routes { 238 | if r.Match(phone) { 239 | return r, true 240 | } 241 | } 242 | return nil, false 243 | } 244 | -------------------------------------------------------------------------------- /smsender/router/router_test.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/minchao/smsender/smsender/model" 8 | "github.com/minchao/smsender/smsender/providers/dummy" 9 | "github.com/minchao/smsender/smsender/store" 10 | dummystore "github.com/minchao/smsender/smsender/store/dummy" 11 | ) 12 | 13 | type testRouteStore struct { 14 | *dummystore.RouteStore 15 | } 16 | 17 | func (rs *testRouteStore) SaveAll(routes []*model.Route) store.Channel { 18 | storeChannel := make(store.Channel, 1) 19 | 20 | go func() { 21 | storeChannel <- store.Result{} 22 | close(storeChannel) 23 | }() 24 | 25 | return storeChannel 26 | } 27 | 28 | func createRouter() *Router { 29 | dummyProvider1 := dummy.New("dummy1") 30 | dummyProvider2 := dummy.New("dummy2") 31 | router := Router{store: &dummystore.Store{DummyRoute: &testRouteStore{}}} 32 | 33 | _ = router.Add(model.NewRoute("default", `^\+.*`, dummyProvider1, true)) 34 | _ = router.Add(model.NewRoute("japan", `^\+81`, dummyProvider2, true)) 35 | _ = router.Add(model.NewRoute("taiwan", `^\+886`, dummyProvider2, true)) 36 | _ = router.Add(model.NewRoute("telco", `^\+886987`, dummyProvider2, true)) 37 | _ = router.Add(model.NewRoute("user", `^\+886987654321`, dummyProvider2, true)) 38 | 39 | return &router 40 | } 41 | 42 | func compareOrder(routes []*model.Route, expected []string) error { 43 | got := []string{} 44 | isNotMatch := false 45 | for i, route := range routes { 46 | got = append(got, route.Name) 47 | if route.Name != expected[i] { 48 | isNotMatch = true 49 | } 50 | } 51 | if isNotMatch { 52 | return fmt.Errorf("routes expecting %v, but got %v", expected, got) 53 | } 54 | return nil 55 | } 56 | 57 | func TestRouter_GetAll(t *testing.T) { 58 | router := createRouter() 59 | 60 | if err := compareOrder(router.GetAll(), []string{"user", "telco", "taiwan", "japan", "default"}); err != nil { 61 | t.Fatal(err) 62 | } 63 | } 64 | 65 | func TestRouter_Get(t *testing.T) { 66 | router := createRouter() 67 | 68 | route := router.Get("japan") 69 | if route == nil || route.Name != "japan" { 70 | t.Fatal("got wrong route") 71 | } 72 | route = router.Get("usa") 73 | if route != nil { 74 | t.Fatal("route should be nil") 75 | } 76 | } 77 | 78 | func TestRouter_Set(t *testing.T) { 79 | router := createRouter() 80 | provider := dummy.New("dummy") 81 | 82 | route := model.NewRoute("user", `^\+886999999999`, provider, true).SetFrom("sender") 83 | 84 | if err := router.Set(route.Name, route.Pattern, route.GetProvider(), route.From, true); err == nil { 85 | newRoute := router.Get("user") 86 | if newRoute == nil { 87 | t.Fatal("route is not equal") 88 | } 89 | if newRoute.Name != route.Name { 90 | t.Fatal("route.Name is not equal") 91 | } 92 | if newRoute.Pattern != route.Pattern { 93 | t.Fatal("route.Pattern is not equal") 94 | } 95 | if newRoute.GetProvider() == nil || newRoute.GetProvider().Name() != route.GetProvider().Name() { 96 | t.Fatal("route.Provider is not equal") 97 | } 98 | if newRoute.From != route.From { 99 | t.Fatal("route.From is not equal") 100 | } 101 | } 102 | if ok := router.Get("user").Match("+886987654321"); ok { 103 | t.Fatal("route should not matched") 104 | } 105 | if ok := router.Get("user").Match("+886999999999"); !ok { 106 | t.Fatal("route should be matched") 107 | } 108 | 109 | if err := router.Set("france", "", provider, "", true); err == nil { 110 | t.Fatal("set route should be failed") 111 | } 112 | } 113 | 114 | func TestRouter_Remove(t *testing.T) { 115 | router := createRouter() 116 | 117 | _ = router.Remove("telco") 118 | _ = router.Remove("japan") 119 | if len(router.routes) != 3 { 120 | t.Fatal("remove route failed") 121 | } 122 | if err := compareOrder(router.routes, []string{"user", "taiwan", "default"}); err != nil { 123 | t.Fatal(err) 124 | } 125 | } 126 | 127 | func TestRouter_Reorder(t *testing.T) { 128 | newRouter := func() *Router { 129 | router := Router{store: &dummystore.Store{DummyRoute: &testRouteStore{}}} 130 | provider := dummy.New("dummy") 131 | for _, r := range []string{"D", "C", "B", "A"} { 132 | _ = router.Add(model.NewRoute(r, "", provider, true)) 133 | } 134 | return &router 135 | } 136 | 137 | router := newRouter() 138 | 139 | if err := router.Reorder(-1, 0, 0); err == nil { 140 | t.Fatal("got incorrect error: nil") 141 | } 142 | if err := router.Reorder(4, 0, 0); err == nil { 143 | t.Fatal("got incorrect error: nil") 144 | } 145 | if err := router.Reorder(1, 0, 0); err == nil { 146 | t.Fatal("got incorrect error: nil") 147 | } 148 | if err := router.Reorder(0, 0, 0); err == nil { 149 | t.Fatal("got incorrect error: nil") 150 | } 151 | if err := router.Reorder(1, 4, 0); err == nil { 152 | t.Fatal("got incorrect error: nil") 153 | } 154 | if err := router.Reorder(0, 1, -1); err == nil { 155 | t.Fatal("got incorrect error: nil") 156 | } 157 | if err := router.Reorder(0, 1, 5); err == nil { 158 | t.Fatal("got incorrect error: nil") 159 | } 160 | 161 | checkReorderRoutes(t, newRouter(), 1, 2, 3, []string{"A", "B", "C", "D"}) 162 | checkReorderRoutes(t, newRouter(), 2, 2, 1, []string{"A", "C", "D", "B"}) 163 | checkReorderRoutes(t, newRouter(), 0, 2, 4, []string{"C", "D", "A", "B"}) 164 | } 165 | 166 | func checkReorderRoutes(t *testing.T, router *Router, rangeStart, rangeLength, insertBefore int, expected []string) { 167 | if err := router.Reorder(rangeStart, rangeLength, insertBefore); err != nil { 168 | t.Fatalf("reorder routes error: %v", err) 169 | } 170 | if err := compareOrder(router.routes, expected); err != nil { 171 | t.Fatal(err) 172 | } 173 | } 174 | 175 | type routeTest struct { 176 | phone string 177 | shouldMatch bool 178 | route string 179 | provider string 180 | } 181 | 182 | func TestRouter_Match(t *testing.T) { 183 | router := createRouter() 184 | 185 | tests := []routeTest{ 186 | { 187 | phone: "+886987654321", 188 | shouldMatch: true, 189 | route: "user", 190 | provider: "dummy2", 191 | }, 192 | { 193 | phone: "+886987654322", 194 | shouldMatch: true, 195 | route: "telco", 196 | provider: "dummy2", 197 | }, 198 | { 199 | phone: "+886900000001", 200 | shouldMatch: true, 201 | route: "taiwan", 202 | provider: "dummy2", 203 | }, 204 | { 205 | phone: "+819000000001", 206 | shouldMatch: true, 207 | route: "japan", 208 | provider: "dummy2", 209 | }, 210 | { 211 | phone: "+10000000001", 212 | shouldMatch: true, 213 | route: "default", 214 | provider: "dummy1", 215 | }, 216 | { 217 | phone: "woo", 218 | shouldMatch: false, 219 | route: "", 220 | provider: "", 221 | }, 222 | } 223 | 224 | for i, test := range tests { 225 | match, ok := router.Match(test.phone) 226 | if test.shouldMatch { 227 | if !ok { 228 | t.Fatalf("test '%d' should match", i) 229 | } 230 | if test.route != match.Name { 231 | t.Fatalf("test '%d' route.Name is not equal", i) 232 | } 233 | if test.provider != match.GetProvider().Name() { 234 | t.Fatalf("test '%d' route.Provider is not equal", i) 235 | } 236 | } else { 237 | if ok { 238 | t.Fatalf("test '%d' should not match", i) 239 | } 240 | } 241 | } 242 | } 243 | 244 | func TestRouter_Match2(t *testing.T) { 245 | router := createRouter() 246 | router.Get("telco").IsActive = false 247 | 248 | if match, ok := router.Match("+886987"); ok { 249 | if match.Name != "taiwan" { 250 | t.Fatal("test route.Name should be 'taiwan'") 251 | } 252 | } else { 253 | t.Fatal("test should match") 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 4 | github.com/aws/aws-sdk-go v1.16.18 h1:ZXmG9Uexu2f2kKK0Onlod3Hl4X77qQvCGx2725fSubI= 5 | github.com/aws/aws-sdk-go v1.16.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 6 | github.com/carlosdp/twiliogo v0.0.0-20140102225436-f61c8230fa91 h1:fIflfwVZsOHJD5nWJXEmdGrHnhVGVWtvVZAXMsHlCDU= 7 | github.com/carlosdp/twiliogo v0.0.0-20140102225436-f61c8230fa91/go.mod h1:pAxCBpjl/0JxYZlWGP/Dyi8f/LQSCQD2WAsG/iNzqQ8= 8 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 9 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 10 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 15 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 16 | github.com/go-playground/form v3.1.3+incompatible h1:GY0v2KFh5AYLVffrkgCFcaN7hcFaX8VXMOAGQ+WtpZs= 17 | github.com/go-playground/form v3.1.3+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg= 18 | github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= 19 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= 20 | github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= 21 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= 22 | github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 23 | github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= 24 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 25 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 26 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 27 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 28 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 29 | github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= 30 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 31 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 32 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 33 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 34 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 35 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= 36 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 37 | github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= 38 | github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= 39 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 40 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 41 | github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= 42 | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= 43 | github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= 44 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 45 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 46 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 47 | github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= 48 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 49 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 50 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 51 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 52 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 53 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/rs/cors v1.6.0 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI= 56 | github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= 57 | github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= 58 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 59 | github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= 60 | github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 61 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 62 | github.com/spf13/afero v1.2.0 h1:O9FblXGxoTc51M+cqr74Bm2Tmt4PvkA5iu/j8HrkNuY= 63 | github.com/spf13/afero v1.2.0/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 64 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 65 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 66 | github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= 67 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 68 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 69 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 70 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 71 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 72 | github.com/spf13/viper v1.3.1 h1:5+8j8FTpnFV4nEImW/ofkzEt8VoOiLXxdYIDsB73T38= 73 | github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 74 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 75 | github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= 76 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 77 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 78 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 79 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 80 | github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 h1:5u+EJUQiosu3JFX0XS0qTf5FznsMOzTjGqavBGuCbo0= 81 | github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2/go.mod h1:4kyMkleCiLkgY6z8gK5BkI01ChBtxR0ro3I1ZDcGM3w= 82 | github.com/ttacon/libphonenumber v1.0.1 h1:sYxYtW16xbklwUA3tJjTGMInEMLYClJjiIX4b7t5Ip0= 83 | github.com/ttacon/libphonenumber v1.0.1/go.mod h1:E0TpmdVMq5dyVlQ7oenAkhsLu86OkUl+yR4OAxyEg/M= 84 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 85 | github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= 86 | github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= 87 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 88 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 89 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 90 | golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc h1:F5tKCVGp+MUAHhKp5MZtGqAlGX3+oCsiL1Q629FL90M= 91 | golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 92 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8= 93 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 94 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 95 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 96 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 97 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 98 | golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb h1:1w588/yEchbPNpa9sEvOcMZYbWHedwJjg4VOAdDHWHk= 99 | golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 100 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 101 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 102 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 103 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 105 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= 107 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 108 | gopkg.in/go-playground/validator.v9 v9.25.0 h1:Q3c4LgUofOEtz0wCE18Q2qwDkATLHLBUOmTvqjNCWkM= 109 | gopkg.in/go-playground/validator.v9 v9.25.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 110 | gopkg.in/njern/gonexmo.v1 v1.0.1 h1:fTAudpN4nmfmdyGU+W4FbU0Ifh+jUsQao/F5neKD2Hs= 111 | gopkg.in/njern/gonexmo.v1 v1.0.1/go.mod h1:PwngK45ZxAYI4/2IVT5FELDeSohbM1e4kyjYTO2OkCg= 112 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 113 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 114 | -------------------------------------------------------------------------------- /smsender/api/handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strconv" 7 | 8 | "github.com/go-playground/form" 9 | "github.com/gorilla/mux" 10 | "github.com/minchao/smsender/smsender/model" 11 | "github.com/minchao/smsender/smsender/utils" 12 | ) 13 | 14 | func (s *Server) Hello(w http.ResponseWriter, r *http.Request) { 15 | _ = render(w, http.StatusOK, "Hello!") 16 | } 17 | 18 | type provider struct { 19 | Name string `json:"name"` 20 | } 21 | 22 | type routeResults struct { 23 | Data []*model.Route `json:"data"` 24 | Providers []*provider `json:"providers"` 25 | } 26 | 27 | func (s *Server) Routes(w http.ResponseWriter, r *http.Request) { 28 | _ = render(w, http.StatusOK, routeResults{Data: s.sender.Router.GetAll(), Providers: s.getProviders()}) 29 | } 30 | 31 | type route struct { 32 | Name string `json:"name" validate:"required"` 33 | Pattern string `json:"pattern" validate:"required,regexp"` 34 | Provider string `json:"provider" validate:"required"` 35 | From string `json:"from"` 36 | IsActive bool `json:"is_active"` 37 | } 38 | 39 | func (s *Server) RoutePost(w http.ResponseWriter, r *http.Request) { 40 | var post route 41 | validate := utils.NewValidate() 42 | _ = validate.RegisterValidation("regexp", utils.IsRegexp) 43 | err := utils.GetInput(r.Body, &post, validate) 44 | if err != nil { 45 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 46 | return 47 | } 48 | if err := s.sender.Router.AddWith(post.Name, post.Pattern, post.Provider, post.From, post.IsActive); err != nil { 49 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 50 | return 51 | } 52 | 53 | _ = render(w, http.StatusOK, post) 54 | } 55 | 56 | type reorder struct { 57 | RangeStart int `json:"range_start" validate:"gte=0"` 58 | RangeLength int `json:"range_length" validate:"gte=0"` 59 | InsertBefore int `json:"insert_before" validate:"gte=0"` 60 | } 61 | 62 | func (s *Server) RouteReorder(w http.ResponseWriter, r *http.Request) { 63 | var reorder reorder 64 | err := utils.GetInput(r.Body, &reorder, utils.NewValidate()) 65 | if err != nil { 66 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 67 | return 68 | } 69 | if reorder.RangeLength == 0 { 70 | reorder.RangeLength = 1 71 | } 72 | if err := s.sender.Router.Reorder(reorder.RangeStart, reorder.RangeLength, reorder.InsertBefore); err != nil { 73 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 74 | return 75 | } 76 | 77 | _ = render(w, http.StatusOK, routeResults{Data: s.sender.Router.GetAll(), Providers: s.getProviders()}) 78 | } 79 | 80 | func (s *Server) RoutePut(w http.ResponseWriter, r *http.Request) { 81 | var put route 82 | validate := utils.NewValidate() 83 | _ = validate.RegisterValidation("regexp", utils.IsRegexp) 84 | err := utils.GetInput(r.Body, &put, validate) 85 | if err != nil { 86 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 87 | return 88 | } 89 | if err := s.sender.Router.SetWith(put.Name, put.Pattern, put.Provider, put.From, put.IsActive); err != nil { 90 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 91 | return 92 | } 93 | 94 | _ = render(w, http.StatusOK, put) 95 | } 96 | 97 | func (s *Server) RouteDelete(w http.ResponseWriter, r *http.Request) { 98 | vars := mux.Vars(r) 99 | routeName := vars["route"] 100 | _ = s.sender.Router.Remove(routeName) 101 | 102 | _ = render(w, http.StatusNoContent, nil) 103 | } 104 | 105 | type routeTestResult struct { 106 | Phone string `json:"phone"` 107 | Route *model.Route `json:"route"` 108 | } 109 | 110 | func (s *Server) RouteTest(w http.ResponseWriter, r *http.Request) { 111 | vars := mux.Vars(r) 112 | phone := vars["phone"] 113 | validate := utils.NewValidate() 114 | _ = validate.RegisterValidation("phone", utils.IsPhoneNumber) 115 | err := validate.Struct(struct { 116 | Phone string `json:"phone" validate:"required,phone"` 117 | }{Phone: phone}) 118 | if err != nil { 119 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 120 | return 121 | } 122 | 123 | route, _ := s.sender.Router.Match(phone) 124 | 125 | _ = render(w, http.StatusOK, routeTestResult{Phone: phone, Route: route}) 126 | } 127 | 128 | type messagesRequest struct { 129 | To string `json:"to" form:"to" validate:"omitempty,phone"` 130 | Status string `json:"status" form:"status"` 131 | Since string `json:"since" form:"since" validate:"omitempty,unixmicro"` 132 | Until string `json:"until" form:"until" validate:"omitempty,unixmicro"` 133 | Limit int `json:"limit" form:"limit" validate:"omitempty,gt=0"` 134 | } 135 | 136 | type paging struct { 137 | Previous string `json:"previous,omitempty"` 138 | Next string `json:"next,omitempty"` 139 | } 140 | 141 | type messagesResults struct { 142 | Data []*model.Message `json:"data"` 143 | Paging paging `json:"paging"` 144 | } 145 | 146 | func (s *Server) Messages(w http.ResponseWriter, r *http.Request) { 147 | var req messagesRequest 148 | if err := form.NewDecoder().Decode(&req, r.URL.Query()); err != nil { 149 | _ = render(w, http.StatusBadRequest, errorMessage{Error: "bad_request", ErrorDescription: err.Error()}) 150 | return 151 | } 152 | if req.Limit == 0 || req.Limit > 100 { 153 | req.Limit = 100 154 | } 155 | 156 | validate := utils.NewValidate() 157 | _ = validate.RegisterValidation("phone", utils.IsPhoneNumber) 158 | _ = validate.RegisterValidation("unixmicro", utils.IsTimeUnixMicro) 159 | if err := validate.Struct(req); err != nil { 160 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 161 | return 162 | } 163 | 164 | params := make(map[string]interface{}) 165 | if req.To != "" { 166 | params["to"] = req.To 167 | } 168 | if req.Status != "" { 169 | params["status"] = req.Status 170 | } 171 | if req.Since != "" { 172 | params["since"], _ = utils.UnixMicroStringToTime(req.Since) 173 | } 174 | if req.Until != "" { 175 | params["until"], _ = utils.UnixMicroStringToTime(req.Until) 176 | } 177 | params["limit"] = req.Limit 178 | 179 | messages, err := s.sender.SearchMessages(params) 180 | if err != nil { 181 | _ = render(w, http.StatusBadRequest, errorMessage{Error: "not_found", ErrorDescription: err.Error()}) 182 | return 183 | } 184 | 185 | results := messagesResults{ 186 | Data: messages, 187 | Paging: paging{}, 188 | } 189 | 190 | if len(messages) == 0 { 191 | results.Data = []*model.Message{} 192 | } else { 193 | // Generate the paging data 194 | since := messages[0].CreatedTime 195 | until := messages[len(messages)-1].CreatedTime 196 | 197 | url, _ := url.Parse("api/messages") 198 | url = s.sender.GetSiteURL().ResolveReference(url) 199 | 200 | values, _ := form.NewEncoder().Encode(&req) 201 | cleanEmptyURLValues(&values) 202 | 203 | delete(params, "until") 204 | 205 | params["since"] = since 206 | prevMessages, _ := s.sender.SearchMessages(params) 207 | if len(prevMessages) > 0 { 208 | values.Del("until") 209 | values.Set("since", strconv.FormatInt(since.UnixNano()/1000, 10)) 210 | url.RawQuery = values.Encode() 211 | 212 | results.Paging.Previous = url.String() 213 | } 214 | 215 | delete(params, "since") 216 | 217 | params["until"] = until 218 | nextMessages, _ := s.sender.SearchMessages(params) 219 | if len(nextMessages) > 0 { 220 | values.Del("since") 221 | values.Set("until", strconv.FormatInt(until.UnixNano()/1000, 10)) 222 | url.RawQuery = values.Encode() 223 | 224 | results.Paging.Next = url.String() 225 | } 226 | } 227 | 228 | _ = render(w, http.StatusOK, results) 229 | } 230 | 231 | type messagess struct { 232 | Data []*model.Message `json:"data"` 233 | } 234 | 235 | func (s *Server) MessagesGetByIds(w http.ResponseWriter, r *http.Request) { 236 | _ = r.ParseForm() 237 | ids := r.Form["ids"] 238 | if err := utils.NewValidate().Struct(struct { 239 | Ids []string `json:"ids" validate:"required,gt=0,dive,required"` 240 | }{Ids: ids}); err != nil { 241 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 242 | return 243 | } 244 | 245 | messages, err := s.sender.GetMessagesByIds(ids) 246 | if err != nil { 247 | _ = render(w, http.StatusNotFound, errorMessage{Error: "not_found", ErrorDescription: err.Error()}) 248 | return 249 | } 250 | results := messagess{Data: []*model.Message{}} 251 | if len(messages) > 0 { 252 | results.Data = messages 253 | } 254 | 255 | _ = render(w, http.StatusOK, results) 256 | } 257 | 258 | type messagesPost struct { 259 | To []string `json:"to" validate:"required,gt=0,dive,phone"` 260 | From string `json:"from"` 261 | Body string `json:"body" validate:"required"` 262 | Async bool `json:"async,omitempty"` 263 | } 264 | 265 | func (s *Server) MessagesPost(w http.ResponseWriter, r *http.Request) { 266 | var post messagesPost 267 | var validate = utils.NewValidate() 268 | _ = validate.RegisterValidation("phone", utils.IsPhoneNumber) 269 | err := utils.GetInput(r.Body, &post, validate) 270 | if err != nil { 271 | _ = render(w, http.StatusBadRequest, formErrorMessage(err)) 272 | return 273 | } 274 | 275 | var ( 276 | count = len(post.To) 277 | jobClones = make([]*model.MessageJob, count) 278 | resultChans = make([]<-chan model.Message, count) 279 | results = make([]*model.Message, count) 280 | ) 281 | 282 | if count > 100 { 283 | post.Async = true 284 | } 285 | 286 | for i := 0; i < count; i++ { 287 | job := model.NewMessageJob(post.To[i], post.From, post.Body, post.Async) 288 | jobClones[i] = job 289 | resultChans[i] = job.Result 290 | 291 | s.out <- job 292 | } 293 | 294 | if post.Async { 295 | for i, job := range jobClones { 296 | results[i] = &job.Message 297 | } 298 | } else { 299 | for i, result := range resultChans { 300 | message := <-result 301 | results[i] = &message 302 | } 303 | } 304 | 305 | _ = render(w, http.StatusOK, messagess{Data: results}) 306 | } 307 | 308 | func (s *Server) getProviders() []*provider { 309 | providers := []*provider{} 310 | for _, p := range s.sender.Router.GetProviders() { 311 | providers = append(providers, &provider{Name: p.Name()}) 312 | } 313 | return providers 314 | } 315 | 316 | func (s *Server) Stats(w http.ResponseWriter, r *http.Request) { 317 | _ = render(w, http.StatusOK, model.NewStats()) 318 | } 319 | 320 | // ShutdownMiddleware will return http.StatusServiceUnavailable if server is already in shutdown progress. 321 | func (s *Server) ShutdownMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 322 | if s.sender.IsShutdown() { 323 | _ = render(w, 324 | http.StatusServiceUnavailable, 325 | errorMessage{ 326 | Error: "service_unavailable", 327 | ErrorDescription: http.StatusText(http.StatusServiceUnavailable), 328 | }) 329 | return 330 | } 331 | next(w, r) 332 | } 333 | -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | 3 | info: 4 | version: "0.0.1" 5 | title: SMSender API 6 | 7 | host: localhost:8080 8 | basePath: /api 9 | tags: 10 | - name: messages 11 | description: | 12 | #### Message Status 13 | 14 | | STATUS | DESCRIPTION | 15 | |---|---| 16 | | accepted | Received your API request to send a message | 17 | | queued | The message is queued to be sent out | 18 | | sending | The message is in the process of dispatching to the upstream carrier | 19 | | failed | The message could not be sent to the upstream carrier | 20 | | sent | The message was successfully accepted by the upstream carrie | 21 | | unknown | Received an undocumented status code from the upstream carrier | 22 | | undelivered | Received that the message was not delivered from the upstream carrier | 23 | | delivered | Received confirmation of message delivery from the upstream carrier | 24 | - name: errors 25 | description: | 26 | All errors will return with the following JSON body: 27 | ```json 28 | { 29 | "error": "ascii_error_code", 30 | "error_description": "Human-readable ASCII text providing additional information, used to assist the client developer in understanding the error that occurred." 31 | } 32 | ``` 33 | schemes: 34 | - http 35 | - https 36 | consumes: 37 | - application/json 38 | produces: 39 | - application/json 40 | paths: 41 | /messages: 42 | get: 43 | tags: 44 | - messages 45 | description: | 46 | Gets Message objects. 47 | parameters: 48 | - in: query 49 | name: since 50 | description: The since of the range (UnixMicro) 51 | type: string 52 | - in: query 53 | name: until 54 | description: The until of the range (UnixMicro) 55 | type: string 56 | - in: query 57 | name: to 58 | description: The destination phone number (E.164 format) 59 | type: string 60 | - in: query 61 | name: status 62 | description: The status of the message 63 | type: string 64 | - in: query 65 | name: limit 66 | description: The Maximum number of objects that may be returned 67 | type: integer 68 | responses: 69 | 200: 70 | description: OK 71 | schema: 72 | type: object 73 | properties: 74 | data: 75 | title: ArrayOfMessage 76 | type: array 77 | items: 78 | $ref: '#/definitions/Message' 79 | 400: 80 | description: Bad request 81 | schema: 82 | $ref: '#/definitions/Error' 83 | post: 84 | tags: 85 | - messages 86 | description: | 87 | Send Message. 88 | parameters: 89 | - in: body 90 | name: body 91 | description: Message 92 | required: false 93 | schema: 94 | type: object 95 | properties: 96 | to: 97 | type: array 98 | items: 99 | type: string 100 | description: The destination phone number (E.164 format) 101 | from: 102 | type: string 103 | description: Sender Id (phone number or alphanumeric) 104 | body: 105 | type: string 106 | description: The text of the message 107 | async: 108 | type: boolean 109 | description: Enable a background sending mode that is optimized for bulk sending 110 | required: 111 | - to 112 | - body 113 | responses: 114 | 200: 115 | description: OK 116 | schema: 117 | type: object 118 | properties: 119 | data: 120 | title: ArrayOfMessage 121 | type: array 122 | items: 123 | $ref: '#/definitions/Message' 124 | 400: 125 | description: Bad request 126 | schema: 127 | $ref: '#/definitions/Error' 128 | /messages/byIds: 129 | get: 130 | tags: 131 | - messages 132 | description: | 133 | Gets specific Message objects. 134 | parameters: 135 | - in: query 136 | name: ids 137 | description: Message Id of array 138 | required: true 139 | type: array 140 | items: 141 | type: string 142 | collectionFormat: multi 143 | responses: 144 | 200: 145 | description: OK 146 | schema: 147 | type: object 148 | properties: 149 | data: 150 | title: ArrayOfMessage 151 | type: array 152 | items: 153 | $ref: '#/definitions/Message' 154 | 400: 155 | description: Bad request 156 | schema: 157 | $ref: '#/definitions/Error' 158 | /routes: 159 | get: 160 | tags: 161 | - routes 162 | description: | 163 | Gets Route objects. 164 | responses: 165 | 200: 166 | description: OK 167 | schema: 168 | type: object 169 | properties: 170 | data: 171 | title: ArrayOfRoute 172 | type: array 173 | items: 174 | $ref: '#/definitions/Route' 175 | providers: 176 | title: ArrayOfProvider 177 | type: array 178 | items: 179 | $ref: '#/definitions/Provider' 180 | post: 181 | tags: 182 | - routes 183 | description: | 184 | Create a Route. 185 | parameters: 186 | - in: body 187 | name: body 188 | description: Route 189 | required: true 190 | schema: 191 | $ref: "#/definitions/Route" 192 | responses: 193 | 200: 194 | description: OK 195 | schema: 196 | $ref: "#/definitions/Route" 197 | 400: 198 | description: Bad request (bad_request, route_already_exists, provider_not_found) 199 | schema: 200 | $ref: '#/definitions/Error' 201 | put: 202 | tags: 203 | - routes 204 | description: | 205 | Reorder a Route in a route list. 206 | parameters: 207 | - in: body 208 | name: body 209 | required: true 210 | schema: 211 | $ref: "#/definitions/Reorder" 212 | responses: 213 | 200: 214 | description: Ok 215 | schema: 216 | type: object 217 | properties: 218 | data: 219 | title: ArrayOfRoute 220 | type: array 221 | items: 222 | $ref: '#/definitions/Route' 223 | providers: 224 | title: ArrayOfProvider 225 | type: array 226 | items: 227 | $ref: '#/definitions/Provider' 228 | 400: 229 | description: Bad request 230 | schema: 231 | $ref: '#/definitions/Error' 232 | /routes/{route}: 233 | put: 234 | tags: 235 | - routes 236 | description: | 237 | Replace a Route. 238 | parameters: 239 | - name: route 240 | in: path 241 | description: Name of Route. 242 | required: true 243 | type: string 244 | - in: body 245 | name: body 246 | description: Message 247 | required: true 248 | schema: 249 | $ref: "#/definitions/Route" 250 | responses: 251 | 200: 252 | description: OK 253 | schema: 254 | $ref: "#/definitions/Route" 255 | 400: 256 | description: Bad request 257 | schema: 258 | $ref: '#/definitions/Error' 259 | 404: 260 | description: Route not found 261 | schema: 262 | $ref: '#/definitions/Error' 263 | delete: 264 | tags: 265 | - routes 266 | description: | 267 | Delete a Route. 268 | parameters: 269 | - name: route 270 | in: path 271 | description: Name of Route. 272 | required: true 273 | type: string 274 | responses: 275 | 204: 276 | description: Ok 277 | 400: 278 | description: Bad request 279 | schema: 280 | $ref: '#/definitions/Error' 281 | 404: 282 | description: Route not found 283 | schema: 284 | $ref: '#/definitions/Error' 285 | definitions: 286 | Message: 287 | properties: 288 | id: 289 | type: string 290 | description: Message Id 291 | to: 292 | type: string 293 | description: The destination phone number (E.164 format) 294 | from: 295 | type: string 296 | description: Sender Id (phone number or alphanumeric) 297 | body: 298 | type: string 299 | description: The text of the message 300 | async: 301 | type: boolean 302 | description: Enable a background sending mode that is optimized for bulk sending 303 | route: 304 | type: string 305 | provider: 306 | type: string 307 | provider_message_id: 308 | description: The upstream carrier's message id 309 | type: string 310 | steps: 311 | type: object 312 | status: 313 | type: string 314 | description: The status of the message 315 | created_time: 316 | type: string 317 | description: Message created time 318 | updated_time: 319 | type: string 320 | description: Message updated time 321 | Route: 322 | type: object 323 | properties: 324 | name: 325 | type: string 326 | pattern: 327 | type: string 328 | description: Phone number regular expression to be matched with a provider 329 | provider: 330 | type: string 331 | description: Provider name 332 | from: 333 | type: string 334 | description: Sender Id (phone number or alphanumeric) 335 | is_active: 336 | type: boolean 337 | required: 338 | - name 339 | - pattern 340 | - provider 341 | Reorder: 342 | type: object 343 | properties: 344 | range_start: 345 | type: integer 346 | description: | 347 | The position of the first route to be reordered. 348 | range_length: 349 | type: integer 350 | description: | 351 | The amount of routes to be reordered. Defaults to 1 if not set. 352 | insert_before: 353 | type: integer 354 | description: | 355 | The position where the routes should be inserted. 356 | Provider: 357 | type: object 358 | properties: 359 | name: 360 | type: string 361 | description: Provider name 362 | Error: 363 | type: object 364 | properties: 365 | error: 366 | type: string 367 | error_description: 368 | type: string 369 | required: 370 | - error 371 | --------------------------------------------------------------------------------