├── 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 |
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 | | Message ID |
43 | {this.message.id} |
44 | Route |
45 | {this.message.route} |
46 |
47 |
48 | | From |
49 | {this.message.form} |
50 | Provider |
51 | {this.message.provider} |
52 |
53 |
54 | | To |
55 | {this.message.to} |
56 |
57 |
58 | | Body |
59 |
60 | {this.message.body}
61 | |
62 |
63 |
64 | | Status |
65 | {this.message.status} |
66 |
67 |
68 | | Created Time |
69 | {this.message.created_time} |
70 |
71 |
72 | | Provider Message ID |
73 | {this.message.original_message_id} |
74 |
75 |
76 |
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 |
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 | [](https://travis-ci.org/minchao/smsender)
4 | [](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 | 
240 |
241 | 
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 |
--------------------------------------------------------------------------------