├── .gitignore ├── sender └── sender.go ├── models ├── database.go ├── string_array.go ├── user.go ├── project.go ├── time_entry.go └── date.go ├── handlers ├── show_help.go ├── remove_entry.go ├── show_projects.go ├── add_project.go ├── create_entry.go ├── create_entry_for_day.go ├── handler.go └── report.go ├── README.md ├── messages └── help.txt └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | tmp 3 | timebot_go 4 | logs/*.log 5 | -------------------------------------------------------------------------------- /sender/sender.go: -------------------------------------------------------------------------------- 1 | package sender 2 | 3 | import "github.com/nlopes/slack" 4 | 5 | var Api *slack.Client 6 | 7 | func SendMessage(receiver, text string) { 8 | Api.SendMessage(receiver, slack.MsgOptionText(text, true), slack.MsgOptionAsUser(true)) 9 | } 10 | -------------------------------------------------------------------------------- /models/database.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | var DB *sql.DB 8 | 9 | type Entity interface { 10 | TableName() string 11 | InsertString() string 12 | } 13 | 14 | type NotFoundError struct{} 15 | 16 | func (e NotFoundError) Error() string { 17 | return "the record was not found" 18 | } 19 | -------------------------------------------------------------------------------- /handlers/show_help.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "os" 5 | "io/ioutil" 6 | "fmt" 7 | "github.com/alex-bogomolov/timebot_go/sender" 8 | ) 9 | 10 | const showHelpRegexp = "^ *help *$" 11 | 12 | func handleShowHelp(uid string) { 13 | projectPath := os.Getenv("TIMEBOT_GO_PATH") 14 | 15 | bytes, err := ioutil.ReadFile(fmt.Sprintf("%s/messages/help.txt", projectPath)) 16 | 17 | if err != nil { 18 | handleError(uid, err) 19 | return 20 | } 21 | 22 | text := string(bytes) 23 | 24 | sender.SendMessage(uid, text) 25 | } 26 | -------------------------------------------------------------------------------- /models/string_array.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type StringArray struct { 4 | array []string 5 | } 6 | 7 | func NewStringArray() StringArray { 8 | out := StringArray{} 9 | out.array = []string{} 10 | return out 11 | } 12 | 13 | func StringArrayFromSlice(slice []string) StringArray { 14 | out := StringArray{} 15 | out.array = slice 16 | return out 17 | } 18 | 19 | func (s *StringArray) Add(str string) { 20 | s.array = append(s.array, str) 21 | } 22 | 23 | func (s StringArray) Join(delimiter string) string { 24 | out := "" 25 | for i, v := range s.array { 26 | out += v 27 | if i != len(s.array) - 1 { 28 | out += delimiter 29 | } 30 | } 31 | return out 32 | } 33 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type User struct { 9 | ID int 10 | Name string 11 | UID string 12 | CreatedAt time.Time 13 | UpdatedAt time.Time 14 | IsSpeaking bool 15 | IsActive bool 16 | } 17 | 18 | func FindUser(uid string) (*User, error) { 19 | selectPart := "id, name, uid, created_at, updated_at, is_speaking, is_active" 20 | sqlQuery := fmt.Sprintf("SELECT %s FROM users WHERE uid = $1", selectPart) 21 | rows, err := DB.Query(sqlQuery, uid) 22 | 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | defer rows.Close() 28 | 29 | user := User{} 30 | 31 | if rows.Next() { 32 | rows.Scan(&user.ID, &user.Name, &user.UID, &user.CreatedAt, &user.UpdatedAt, &user.IsSpeaking, &user.IsActive) 33 | } else { 34 | return nil, nil 35 | } 36 | 37 | return &user, nil 38 | } 39 | -------------------------------------------------------------------------------- /handlers/remove_entry.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "github.com/alex-bogomolov/timebot_go/models" 7 | "github.com/alex-bogomolov/timebot_go/sender" 8 | "fmt" 9 | ) 10 | 11 | const removeEntryRegexp = "^ *remove entry (\\d+) *$" 12 | 13 | func handleRemoveEntry(uid, text string) { 14 | r := regexp.MustCompile(removeEntryRegexp) 15 | 16 | id, err := strconv.ParseInt(r.FindStringSubmatch(text)[1], 10, 64) 17 | 18 | if err != nil { 19 | handleError(uid, err) 20 | return 21 | } 22 | 23 | user, err := models.FindUser(uid) 24 | 25 | if err != nil { 26 | handleError(uid, err) 27 | return 28 | } 29 | 30 | entry, err := models.FindTimeEntryByID(int(id)) 31 | 32 | if err != nil { 33 | handleError(uid, err) 34 | return 35 | } 36 | 37 | if user.ID != entry.UserId { 38 | sender.SendMessage(uid, "You are not allowed to remove other user's entries.") 39 | return 40 | } 41 | 42 | entry.Delete() 43 | 44 | sender.SendMessage(uid, fmt.Sprintf("Time entry with id %d was successfully deleted.", entry.ID)) 45 | } 46 | -------------------------------------------------------------------------------- /handlers/show_projects.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/alex-bogomolov/timebot_go/models" 5 | "fmt" 6 | "github.com/alex-bogomolov/timebot_go/sender" 7 | ) 8 | 9 | const showProjectsRegexp = "^ *projects *$" 10 | 11 | func handleShowProjects(uid string) { 12 | projects, err := models.GetAllProjects() 13 | 14 | if err != nil { 15 | handleError(uid, err) 16 | } 17 | 18 | stringArray := models.NewStringArray() 19 | 20 | largestLength := longestProjectNameLength(projects) 21 | 22 | for _, project := range projects { 23 | stringArray.Add(fmt.Sprintf("%s Alias: %s", rightPad(project.Name, largestLength), project.Alias.String)) 24 | } 25 | 26 | sender.SendMessage(uid, fmt.Sprintf("```%s```", stringArray.Join("\n"))) 27 | } 28 | 29 | 30 | func rightPad(s string, length int) string { 31 | out := s 32 | 33 | for len(out) < length { 34 | out += " " 35 | } 36 | 37 | return out 38 | } 39 | 40 | func longestProjectNameLength(projects []*models.Project) int { 41 | max := 0 42 | 43 | for _, project := range projects { 44 | if len(project.Name) > max { 45 | max = len(project.Name) 46 | } 47 | } 48 | 49 | return max 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timebot 2 | 3 | **Timebot-go** is a **Slack** bot for holding everyday survey. The bot asks developers how much did they work that day. It also asks on which project they have been working for. 4 | 5 | If developer doesn't reply, the bot reminds him about the question. The developer can also log time for any day of the year (except the future days). If a developer didn't log time for previous day, the bot will remind him to report the time in the morning. If a project doesn't exist, a developer can create it. The bot can also provide a time report for a week and since the beginning of the month. 6 | 7 | ## Requirements 8 | - Go 9 | - PostgreSQL 10 | 11 | ## License 12 | timebot-go is Copyright © 2015-2019 Codica. It is released under the [MIT License](https://opensource.org/licenses/MIT). 13 | 14 | ## About Codica 15 | 16 | [![Codica logo](https://www.codica.com/assets/images/logo/logo.svg)](https://www.codica.com) 17 | 18 | timebot-go is maintained and funded by Codica. The names and logos for Codica are trademarks of Codica. 19 | 20 | We love open source software! See [our other projects](https://github.com/codica2) or [hire us](https://www.codica.com/) to design, develop, and grow your product. 21 | -------------------------------------------------------------------------------- /handlers/add_project.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "regexp" 5 | "github.com/alex-bogomolov/timebot_go/models" 6 | "github.com/alex-bogomolov/timebot_go/sender" 7 | "fmt" 8 | ) 9 | 10 | const addProjectRegexp = "^ *add project (\\w.*?) *$" 11 | 12 | const projectNameMinimumLength = 4 13 | 14 | func handleAddProject(uid, text string) { 15 | r := regexp.MustCompile(addProjectRegexp) 16 | projectName := r.FindStringSubmatch(text)[1] 17 | 18 | _, err := models.FindProjectByNameOrAlias(projectName) 19 | 20 | if err == nil { 21 | sender.SendMessage(uid, fmt.Sprintf("Project with name \"%s\" already exists.", projectName)) 22 | return 23 | } else if _, ok := err.(models.NotFoundError); !ok { 24 | handleError(uid, err) 25 | return 26 | } 27 | 28 | if len(projectName) < projectNameMinimumLength { 29 | sender.SendMessage(uid, fmt.Sprintf("The minimum lenth for a project's name is %d.", projectNameMinimumLength)) 30 | return 31 | } 32 | 33 | p := models.Project{} 34 | 35 | p.Name = projectName 36 | 37 | err = p.Create() 38 | 39 | if err != nil { 40 | handleError(uid, err) 41 | return 42 | } 43 | 44 | sender.SendMessage(uid, fmt.Sprintf("The project with name \"%s\" was successfully created.", p.Name)) 45 | } 46 | -------------------------------------------------------------------------------- /messages/help.txt: -------------------------------------------------------------------------------- 1 | > `help` - prints help. 2 | 3 | >`projects` - prints all available projects projects. 4 | 5 | > To enter time, write `PROJECT_NAME HOURS:MINUTES COMMENT`. 6 | > For example, `Timebot 8:00 some comment`. 7 | 8 | > To create an entry for the specific date, write `update DAY.MONTH.YEAR(OPTIONAL) PROJECT_NAME HOURS:MINUTES COMMENT`. 9 | > For example, `update 25.07 Timebot 8:00 a nice comment`. 10 | 11 | > To edit an existing time entry, write `edit TIME_ENTRY_ID HOURS:MINUTES COMMENT`. 12 | > For example, `edit 1 Timebot 8:00 a nice comment`. *(NOT IMPLEMENTED)* 13 | 14 | > To remove a time entry, write `remove entry ID`. 15 | > For example, `remove entry 1`. 16 | 17 | > To add new project, write `add project PROJECT_NAME`. 18 | > For example, `add project Timebot`. 19 | 20 | > To get report for a week / last week, write `show week` / `show last week`. 21 | 22 | > To get report since the beginning of the month / last month, write `show month` / `show last month`. 23 | 24 | > To get report by project, write `show week` / `show last week` / `show month` / `show last month` + `PROJECT_NAME`. 25 | > For example, `show week Timebot` / `show last month Timebot` etc. 26 | 27 | > If you are absent on some days, write `set REASON DAY.MONTH.YEAR(OPTIONAL) - DAY.MONTH.YEAR(OPTIONAL)`. 28 | > For example, `set vacation 15.08 - 19.08`. 29 | > Valid reasons: Vacation, Illness. *(NOT IMPLEMENTED)* 30 | -------------------------------------------------------------------------------- /handlers/create_entry.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/alex-bogomolov/timebot_go/models" 6 | "github.com/alex-bogomolov/timebot_go/sender" 7 | "github.com/nlopes/slack" 8 | "regexp" 9 | "time" 10 | ) 11 | 12 | const newEntryStringRegexp = "^ *(.*) (\\d?\\d:[0-5]\\d) ([^\\s](?:.|\\s)*[^\\s])$" 13 | 14 | func handleNewEntry(message *slack.Msg) { 15 | newEntryRegexp := regexp.MustCompile(newEntryStringRegexp) 16 | matches := newEntryRegexp.FindStringSubmatch(message.Text) 17 | 18 | projectName := matches[1] 19 | entryTime := matches[2] 20 | minutes, err := parseTime(entryTime) 21 | 22 | if err != nil { 23 | handleError(message.User, err) 24 | return 25 | } 26 | 27 | details := matches[3] 28 | user, err := models.FindUser(message.User) 29 | 30 | if err != nil { 31 | handleError(message.User, err) 32 | return 33 | } 34 | 35 | project, err := models.FindProjectByNameOrAlias(projectName) 36 | 37 | if _, ok := err.(models.NotFoundError); ok { 38 | sender.SendMessage(user.UID, "The project with name \""+projectName+"\" was not found.") 39 | return 40 | } else if err != nil { 41 | handleError(user.UID, err) 42 | return 43 | } 44 | 45 | timeEntry := models.TimeEntry{ 46 | UserId: user.ID, 47 | Date: models.NewDate(time.Now()), 48 | ProjectId: project.ID, 49 | Details: sql.NullString{details, true}, 50 | Minutes: minutes, 51 | CreatedAt: time.Now(), 52 | UpdatedAt: time.Now(), 53 | Time: entryTime, 54 | } 55 | 56 | err = timeEntry.Create() 57 | 58 | if err != nil { 59 | handleError(message.User, err) 60 | return 61 | } 62 | 63 | sender.SendMessage(user.UID, "The time entry was successfully created.") 64 | } 65 | -------------------------------------------------------------------------------- /handlers/create_entry_for_day.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/nlopes/slack" 5 | "regexp" 6 | "github.com/alex-bogomolov/timebot_go/models" 7 | "github.com/alex-bogomolov/timebot_go/sender" 8 | "database/sql" 9 | "time" 10 | ) 11 | 12 | const createEntryForDayRegexp = "^ *update (\\d?\\d\\.\\d?\\d(?:\\.(?:\\d\\d)?\\d\\d)?) (.*) (\\d?\\d:[0-5]\\d) ([^\\s](?:.|\\s)*[^\\s]) *$" 13 | 14 | func handleCreateEntryForDay(msg *slack.Msg) { 15 | r := regexp.MustCompile(createEntryForDayRegexp) 16 | 17 | matchData := r.FindStringSubmatch(msg.Text) 18 | 19 | date := matchData[1] 20 | projectName := matchData[2] 21 | t := matchData[3] 22 | details := matchData[4] 23 | 24 | user, err := models.FindUser(msg.User) 25 | 26 | if err != nil { 27 | handleError(msg.User, err) 28 | return 29 | } 30 | 31 | project, err := models.FindProjectByNameOrAlias(projectName) 32 | 33 | if _, ok := err.(models.NotFoundError); ok { 34 | sender.SendMessage("Project with name \"%s\" was not found.", projectName) 35 | return 36 | } 37 | 38 | minutes, err := parseTime(t) 39 | 40 | if err != nil { 41 | handleError(msg.User, err) 42 | return 43 | } 44 | 45 | d, err := models.ParseDate(date) 46 | 47 | if err != nil { 48 | handleError(msg.User, err) 49 | return 50 | } 51 | 52 | timeEntry := models.TimeEntry{} 53 | 54 | now := time.Now() 55 | 56 | timeEntry.UserId = user.ID 57 | timeEntry.ProjectId = project.ID 58 | timeEntry.Date = *d 59 | timeEntry.Time = t 60 | timeEntry.Minutes = minutes 61 | timeEntry.Details = sql.NullString{details, true} 62 | timeEntry.CreatedAt = now 63 | timeEntry.UpdatedAt = now 64 | 65 | err = timeEntry.Create() 66 | 67 | if err != nil { 68 | handleError(msg.User, err) 69 | return 70 | } 71 | 72 | sender.SendMessage(user.UID, "Time entry was successfully created") 73 | } 74 | -------------------------------------------------------------------------------- /models/project.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type Project struct { 10 | ID int 11 | Name string 12 | Alias sql.NullString 13 | CreatedAt time.Time 14 | UpdatedAt time.Time 15 | } 16 | 17 | func FindProjectByNameOrAlias(name string) (*Project, error) { 18 | selectPart := "id, name, alias, created_at, updated_at" 19 | downcasedName := strings.ToLower(name) 20 | 21 | rows, err := DB.Query("SELECT "+selectPart+" FROM projects WHERE lower(name) = $1 OR lower(alias) = $2", downcasedName, downcasedName) 22 | 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | defer rows.Close() 28 | 29 | project := Project{} 30 | 31 | if rows.Next() { 32 | err = rows.Scan(&project.ID, &project.Name, &project.Alias, &project.CreatedAt, &project.UpdatedAt) 33 | 34 | if err != nil { 35 | return nil, err 36 | } 37 | } else { 38 | return nil, NotFoundError{} 39 | } 40 | 41 | return &project, nil 42 | } 43 | 44 | func GetAllProjects() ([]*Project, error) { 45 | selectPart := "id, name, alias, created_at, updated_at" 46 | 47 | rows, err := DB.Query("SELECT "+selectPart+" FROM projects ORDER BY name") 48 | 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | defer rows.Close() 54 | 55 | out := []*Project{} 56 | 57 | for rows.Next() { 58 | p := &Project{} 59 | 60 | err = rows.Scan(&p.ID, &p.Name, &p.Alias, &p.CreatedAt, &p.UpdatedAt) 61 | 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | out = append(out, p) 67 | } 68 | 69 | return out, nil 70 | } 71 | 72 | func (p *Project) Create() error { 73 | transaction, err := DB.Begin() 74 | 75 | if err != nil { 76 | return err 77 | } 78 | 79 | now := time.Now() 80 | 81 | p.CreatedAt = now 82 | p.UpdatedAt = now 83 | 84 | _, err = transaction.Exec("INSERT INTO projects (name, created_at, updated_at) VALUES ($1, $2, $3)", p.Name, p.CreatedAt, p.UpdatedAt) 85 | 86 | if err != nil { 87 | return err 88 | } 89 | 90 | err = transaction.Commit() 91 | 92 | if err != nil { 93 | return err 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /handlers/handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alex-bogomolov/timebot_go/sender" 6 | "github.com/nlopes/slack" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "os" 11 | ) 12 | 13 | func HandleMessage(message *slack.Msg) { 14 | defer (func() { 15 | if r := recover(); r != nil { 16 | sender.SendMessage(message.User, fmt.Sprint(r)) 17 | text := fmt.Sprintf("PANIC: %q; %v\n", message.Text, r) 18 | f, err := os.OpenFile("logs/error.log", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) 19 | if err != nil { 20 | fmt.Println(err) 21 | } 22 | f.Write([]byte(text)) 23 | f.Close() 24 | } 25 | })() 26 | 27 | if matched, err := regexp.MatchString(createEntryForDayRegexp, message.Text); matched && err == nil { 28 | handleCreateEntryForDay(message) 29 | } else if matched, err = regexp.MatchString(newEntryStringRegexp, message.Text); matched && err == nil { 30 | handleNewEntry(message) 31 | } else if matched, err = regexp.MatchString(reportRegexpString, message.Text); matched && err == nil { 32 | handleReport(message) 33 | } else if matched, err = regexp.MatchString(showProjectsRegexp, message.Text); matched && err == nil { 34 | handleShowProjects(message.User) 35 | } else if matched, err = regexp.MatchString(addProjectRegexp, message.Text); matched && err == nil { 36 | handleAddProject(message.User, message.Text) 37 | } else if matched, err = regexp.MatchString(showHelpRegexp, strings.ToLower(message.Text)); matched && err == nil { 38 | handleShowHelp(message.User) 39 | } else if matched, err = regexp.MatchString(removeEntryRegexp, strings.ToLower(message.Text)); matched && err == nil { 40 | handleRemoveEntry(message.User, message.Text) 41 | } else { 42 | sender.SendMessage(message.User, "Sorry. I don't understand you.") 43 | } 44 | } 45 | 46 | func parseTime(time string) (int, error) { 47 | regex := regexp.MustCompile("^(\\d?\\d):(\\d\\d)$") 48 | 49 | matchData := regex.FindStringSubmatch(time) 50 | 51 | hours, err := strconv.ParseInt(matchData[1], 10, 64) 52 | 53 | if err != nil { 54 | return 0, err 55 | } 56 | 57 | minutes, err := strconv.ParseInt(matchData[2], 10, 64) 58 | 59 | if err != nil { 60 | return 0, err 61 | } 62 | 63 | return int(hours)*60 + int(minutes), nil 64 | } 65 | 66 | func handleError(uid string, err error) { 67 | sender.SendMessage(uid, fmt.Sprintf("An error occured: %s", err.Error())) 68 | } 69 | -------------------------------------------------------------------------------- /handlers/report.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alex-bogomolov/timebot_go/models" 6 | "github.com/alex-bogomolov/timebot_go/sender" 7 | "github.com/nlopes/slack" 8 | "regexp" 9 | ) 10 | 11 | const reportRegexpString = "^ *show (week|last week|month|last month)(?: (.*?))? *$" 12 | 13 | func handleReport(message *slack.Msg) { 14 | reportRegexp := regexp.MustCompile(reportRegexpString) 15 | 16 | matchData := reportRegexp.FindStringSubmatch(message.Text) 17 | 18 | interval := matchData[1] 19 | projectName := matchData[2] 20 | 21 | user, err := models.FindUser(message.User) 22 | 23 | if err != nil { 24 | handleError(message.User, err) 25 | return 26 | } 27 | 28 | var project *models.Project 29 | 30 | if len(projectName) != 0 { 31 | project, err = models.FindProjectByNameOrAlias(projectName) 32 | 33 | if _, ok := err.(models.NotFoundError); ok { 34 | sender.SendMessage(user.UID, fmt.Sprintf("The project with name \"%s\" was not found.", projectName)) 35 | return 36 | } else if err != nil { 37 | handleError(user.UID, err) 38 | return 39 | } 40 | } 41 | 42 | timeEntries := []*models.TimeEntry{} 43 | var from, to models.Date 44 | 45 | switch interval { 46 | case "week": 47 | from = models.Today().StartOfWeek() 48 | to = models.Today().EndOfWeek() 49 | case "last week": 50 | from = models.Today().StartOfWeek().Minus(7) 51 | to = models.Today().EndOfWeek().Minus(7) 52 | case "month": 53 | from = models.BeginningOfMonth() 54 | to = models.EndOfMonth() 55 | case "last month": 56 | from = models.BeginningOfLastMonth() 57 | to = models.EndOfLastMonth() 58 | } 59 | 60 | timeEntries, err = models.GetTimeEntriesInPeriodWithProjectAndUser(user, project, from, to) 61 | 62 | if err != nil { 63 | handleError(user.UID, err) 64 | return 65 | } 66 | 67 | displayTimeEntries(timeEntries, from, to, user) 68 | } 69 | 70 | func displayTimeEntries(timeEntries []*models.TimeEntry, from models.Date, to models.Date, user *models.User) { 71 | days := make(map[string][]*models.TimeEntry) 72 | 73 | today := models.Today() 74 | 75 | for _, timeEntry := range timeEntries { 76 | if entries, ok := days[timeEntry.Date.String()]; ok { 77 | days[timeEntry.Date.String()] = append(entries, timeEntry) 78 | } else { 79 | days[timeEntry.Date.String()] = []*models.TimeEntry{timeEntry} 80 | } 81 | } 82 | 83 | stringArray := models.NewStringArray() 84 | 85 | for d := from; to.CompareTo(&d) >= 0 && d.CompareTo(&today) <= 0; d = d.Plus(1) { 86 | entries := days[d.String()] 87 | 88 | line := fmt.Sprintf("`%s:", d.Format("02.01.06` (Monday)")) 89 | 90 | if len(entries) == 0 { 91 | line += " No entries" 92 | stringArray.Add(line) 93 | } else { 94 | stringArray.Add(line) 95 | for _, entry := range entries { 96 | stringArray.Add(entry.Description()) 97 | } 98 | } 99 | } 100 | 101 | stringArray.Add(fmt.Sprintf("*Total*: %s.", totalTime(timeEntries))) 102 | 103 | sender.SendMessage(user.UID, stringArray.Join("\n")) 104 | } 105 | 106 | func totalTime(entries []*models.TimeEntry) string { 107 | total := 0 108 | 109 | for _, entry := range entries { 110 | total += entry.Minutes 111 | } 112 | 113 | minutes := total % 60 114 | hours := total / 60 115 | 116 | return fmt.Sprintf("%d:%02d", hours, minutes) 117 | } 118 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/alex-bogomolov/timebot_go/handlers" 7 | "github.com/alex-bogomolov/timebot_go/models" 8 | "github.com/alex-bogomolov/timebot_go/sender" 9 | _ "github.com/lib/pq" 10 | "github.com/nlopes/slack" 11 | "os" 12 | "runtime" 13 | "time" 14 | ) 15 | 16 | var publicChannelsMap map[string]bool 17 | var timebotId string 18 | var usersMap map[string]string 19 | 20 | func main() { 21 | fmt.Printf("Number of logical processors: %d\n", runtime.GOMAXPROCS(0)) 22 | slackToken := os.Getenv("SLACK_TOKEN") 23 | 24 | api := slack.New(slackToken) 25 | sender.Api = api 26 | 27 | var err error 28 | 29 | timebotId, usersMap, err = getUsers(api) 30 | 31 | if err != nil { 32 | fmt.Println(err) 33 | return 34 | } 35 | 36 | models.DB, err = connectToDatabase() 37 | 38 | if err != nil { 39 | fmt.Println(err) 40 | return 41 | } 42 | 43 | publicChannels, err := api.GetChannels(true) 44 | 45 | if err != nil { 46 | fmt.Println(err) 47 | return 48 | } 49 | 50 | publicChannelsMap = make(map[string]bool) 51 | 52 | for _, channel := range publicChannels { 53 | publicChannelsMap[channel.ID] = true 54 | } 55 | 56 | startBot(api) 57 | } 58 | 59 | func init () { 60 | if _, err := os.Stat("logs"); os.IsNotExist(err) { 61 | os.Mkdir("logs", 0700) 62 | } 63 | if _, err := os.Stat("messages/help.txt"); err != nil { 64 | panic(err) 65 | } 66 | } 67 | 68 | func getUsers(api *slack.Client) (string, map[string]string, error) { 69 | users, err := api.GetUsers() 70 | 71 | if err != nil { 72 | return "", nil, err 73 | } 74 | 75 | usersMap := make(map[string]string) 76 | timebotId := "" 77 | 78 | for _, user := range users { 79 | usersMap[user.ID] = user.Name 80 | if user.Name == "timebot" { 81 | timebotId = user.ID 82 | } 83 | } 84 | 85 | return timebotId, usersMap, nil 86 | } 87 | 88 | func startBot(api *slack.Client) { 89 | rtm := api.NewRTM() 90 | 91 | go rtm.ManageConnection() 92 | 93 | for msg := range rtm.IncomingEvents { 94 | switch event := msg.Data.(type) { 95 | case *slack.ConnectedEvent: 96 | fmt.Println("Connected to Slack") 97 | case *slack.MessageEvent: 98 | if messageIsProcessable(&event.Msg) && underDevelopment(&event.Msg) { 99 | go sender.SendMessage(event.Msg.User, "Sorry, I am under maintenance now") 100 | } else if messageIsProcessable(&event.Msg) { 101 | go (func() { 102 | logMessage(&event.Msg) 103 | handlers.HandleMessage(&event.Msg) 104 | })() 105 | } 106 | } 107 | } 108 | } 109 | 110 | func messageIsProcessable(msg *slack.Msg) bool { 111 | return msg.User != timebotId && messageIsNotFromPublicChannel(msg.Channel) 112 | } 113 | 114 | func messageIsNotFromPublicChannel(channelId string) bool { 115 | if _, ok := publicChannelsMap[channelId]; ok { 116 | return false 117 | } else { 118 | return true 119 | } 120 | } 121 | 122 | func underDevelopment(msg *slack.Msg) bool { 123 | return os.Getenv("GOLANG_ENV") == "development" && msg.User != "U0L1X3Q4D" 124 | } 125 | 126 | func connectToDatabase() (*sql.DB, error) { 127 | db, dbError := sql.Open("postgres", os.Getenv("TIMEBOT_GO_DB_CONNECTION_STRING")) 128 | 129 | if dbError != nil { 130 | return nil, dbError 131 | } else { 132 | return db, nil 133 | } 134 | } 135 | 136 | func logMessage(msg *slack.Msg) { 137 | location, _ := time.LoadLocation("Europe/Kiev") 138 | t := time.Now().In(location) 139 | 140 | filename := t.Format("bot-2006-01-02.log") 141 | 142 | f, err := os.OpenFile("logs/" + filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) 143 | 144 | if err != nil { 145 | fmt.Println(err) 146 | return 147 | } 148 | 149 | defer f.Close() 150 | 151 | f.Write([]byte(fmt.Sprintf("%s - %s - %q\n", t.Format("02.01.06 15:04:05"), usersMap[msg.User], msg.Text))) 152 | } 153 | -------------------------------------------------------------------------------- /models/time_entry.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type TimeEntry struct { 10 | ID int 11 | UserId int 12 | Date Date 13 | Time string 14 | Minutes int 15 | Details sql.NullString 16 | CreatedAt time.Time 17 | UpdatedAt time.Time 18 | ProjectId int 19 | Project *Project 20 | } 21 | 22 | func (t *TimeEntry) Create() error { 23 | transaction, err := DB.Begin() 24 | 25 | if err != nil { 26 | return err 27 | } 28 | 29 | _, err = transaction.Exec("INSERT INTO time_entries (user_id, date, time, minutes, details, project_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", t.UserId, t.Date._time, t.Time, t.Minutes, t.Details, t.ProjectId, t.CreatedAt, t.UpdatedAt) 30 | 31 | if err != nil { 32 | return err 33 | } 34 | 35 | err = transaction.Commit() 36 | 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | 44 | func (t *TimeEntry) Description() string { 45 | // "*#{id}: #{project.name}* - #{time} - #{details}" 46 | 47 | return fmt.Sprintf("*%d: %s* - %s - %s", t.ID, t.Project.Name, t.Time, t.Details.String) 48 | } 49 | 50 | func GetTimeEntriesInPeriodWithProjectAndUser(user *User, project *Project, from Date, to Date) ([]*TimeEntry, error) { 51 | selectPart := "time_entries.id, time_entries.user_id, time_entries.date, time_entries.time, time_entries.minutes, time_entries.details, time_entries.created_at, time_entries.updated_at, time_entries.project_id" 52 | projectSelectPart := "projects.id, projects.name, projects.alias, projects.created_at, projects.updated_at" 53 | joins := "INNER JOIN projects ON projects.id = time_entries.project_id" 54 | sqlQuery := fmt.Sprintf("SELECT %s, %s FROM time_entries %s WHERE user_id = $1 AND date >= %s and date <= %s", selectPart, projectSelectPart, joins, from.SQL(), to.SQL()) 55 | 56 | var rows *sql.Rows 57 | var err error 58 | 59 | if project == nil { 60 | rows, err = DB.Query(sqlQuery, user.ID) 61 | } else { 62 | rows, err = DB.Query(sqlQuery+" AND project_id = $2", user.ID, project.ID) 63 | } 64 | 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | defer rows.Close() 70 | 71 | timeEntries := []*TimeEntry{} 72 | 73 | for rows.Next() { 74 | timeEntry := TimeEntry{} 75 | timeEntry.Project = &Project{} 76 | 77 | var d time.Time 78 | 79 | err = rows.Scan(&timeEntry.ID, &timeEntry.UserId, &d, &timeEntry.Time, &timeEntry.Minutes, 80 | &timeEntry.Details, &timeEntry.CreatedAt, &timeEntry.UpdatedAt, &timeEntry.ProjectId, 81 | &timeEntry.Project.ID, &timeEntry.Project.Name, &timeEntry.Project.Alias, &timeEntry.Project.CreatedAt, 82 | &timeEntry.Project.UpdatedAt) 83 | 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | timeEntry.Date = NewDate(d) 89 | 90 | timeEntries = append(timeEntries, &timeEntry) 91 | } 92 | 93 | return timeEntries, nil 94 | } 95 | 96 | func FindTimeEntryByID(id int) (*TimeEntry, error) { 97 | selectPart := "time_entries.id, time_entries.user_id, time_entries.date, time_entries.time, time_entries.minutes, time_entries.details, time_entries.created_at, time_entries.updated_at, time_entries.project_id" 98 | projectSelectPart := "projects.id, projects.name, projects.alias, projects.created_at, projects.updated_at" 99 | joins := "INNER JOIN projects ON projects.id = time_entries.project_id" 100 | sqlQuery := fmt.Sprintf("SELECT %s, %s FROM time_entries %s WHERE time_entries.id = $1", selectPart, projectSelectPart, joins) 101 | rows, err := DB.Query(sqlQuery, id) 102 | 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | defer rows.Close() 108 | 109 | if rows.Next() { 110 | timeEntry := TimeEntry{} 111 | timeEntry.Project = &Project{} 112 | 113 | var d time.Time 114 | 115 | err = rows.Scan(&timeEntry.ID, &timeEntry.UserId, &d, &timeEntry.Time, &timeEntry.Minutes, 116 | &timeEntry.Details, &timeEntry.CreatedAt, &timeEntry.UpdatedAt, &timeEntry.ProjectId, 117 | &timeEntry.Project.ID, &timeEntry.Project.Name, &timeEntry.Project.Alias, &timeEntry.Project.CreatedAt, 118 | &timeEntry.Project.UpdatedAt) 119 | 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | timeEntry.Date = NewDate(d) 125 | return &timeEntry, nil; 126 | } else { 127 | return nil, NotFoundError{} 128 | } 129 | } 130 | 131 | func (t TimeEntry) String() string { 132 | return fmt.Sprintf("User ID: %d; Time: %s; Details: \"%s\"", t.ID, t.Time, t.Details.String) 133 | } 134 | 135 | func (t TimeEntry) Delete() error { 136 | transaction, err := DB.Begin() 137 | 138 | if err != nil { 139 | return err 140 | } 141 | 142 | _, err = transaction.Exec("DELETE FROM time_entries WHERE id = $1", t.ID) 143 | 144 | if err != nil { 145 | return err 146 | } 147 | 148 | err = transaction.Commit() 149 | 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /models/date.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | "regexp" 7 | "strconv" 8 | ) 9 | 10 | type Date struct { 11 | _time time.Time 12 | 13 | Year int 14 | Month int 15 | Day int 16 | } 17 | 18 | type InvalidDate struct {} 19 | 20 | func (id InvalidDate) Error() string { 21 | return "the date is invalid" 22 | } 23 | 24 | const dateRegexp = "^(\\d\\d\\d\\d)-(\\d\\d)-(\\d\\d)$" 25 | 26 | func NewDate(t time.Time) Date { 27 | date := Date{} 28 | 29 | date._time = t 30 | date.Year = t.Year() 31 | date.Month = int(t.Month()) 32 | date.Day = t.Day() 33 | 34 | return date 35 | } 36 | 37 | func DateFromString(s string) (*Date, error) { 38 | matched, err := regexp.MatchString(dateRegexp, s) 39 | 40 | if err != nil { 41 | return nil, err 42 | } else if !matched { 43 | return nil, InvalidDate{} 44 | } 45 | 46 | r := regexp.MustCompile(dateRegexp) 47 | 48 | matchData := r.FindStringSubmatch(s) 49 | 50 | year, err := strconv.ParseInt(matchData[1], 10, 64) 51 | month, err := strconv.ParseInt(matchData[2], 10, 64) 52 | day, err := strconv.ParseInt(matchData[3], 10, 64) 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | location, err := time.LoadLocation(time.Now().Location().String()) 59 | 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | out := NewDate(time.Date(int(year), time.Month(month), int(day), 12, 0, 0, 0, location)) 65 | 66 | return &out, nil 67 | } 68 | 69 | func (d Date) StartOfWeek() Date { 70 | unix := d._time.Unix() 71 | weekday := int64(d._time.Weekday()) 72 | 73 | if weekday == 0 { 74 | return NewDate(time.Unix(unix-24*60*60*6, 0)) 75 | } else { 76 | return NewDate(time.Unix(unix-24*60*60*(weekday-1), 0)) 77 | } 78 | } 79 | 80 | func (d Date) EndOfWeek() Date { 81 | unix := d._time.Unix() 82 | weekday := int64(d._time.Weekday()) 83 | 84 | if weekday == 0 { 85 | return d 86 | } else { 87 | return NewDate(time.Unix(unix+24*60*60*(7-weekday), 0)) 88 | } 89 | } 90 | 91 | func (d Date) Format(s string) string { 92 | return d._time.Format(s) 93 | } 94 | 95 | func Today() Date { 96 | return NewDate(time.Now()) 97 | } 98 | 99 | func (d Date) String() string { 100 | return fmt.Sprintf("%d-%s-%s", d.Year, padNumber(d.Month), padNumber(d.Day)) 101 | } 102 | 103 | func padNumber(number int) string { 104 | s := fmt.Sprint(number) 105 | 106 | if len(s) < 2 { 107 | s = "0" + s 108 | } 109 | 110 | return s 111 | } 112 | 113 | func (d Date) Minus(days int) Date { 114 | out := Date{} 115 | 116 | t := time.Unix(d._time.Unix()-int64(days)*24*60*60, 0) 117 | 118 | out._time = t 119 | out.Year = t.Year() 120 | out.Month = int(t.Month()) 121 | out.Day = t.Day() 122 | 123 | return out 124 | } 125 | 126 | func (d Date) Plus(days int) Date { 127 | out := Date{} 128 | 129 | t := time.Unix(d._time.Unix()+int64(days)*24*60*60, 0) 130 | 131 | out._time = t 132 | out.Year = t.Year() 133 | out.Month = int(t.Month()) 134 | out.Day = t.Day() 135 | 136 | return out 137 | } 138 | 139 | func (d Date) Equal(dt *Date) bool { 140 | return d.Year == dt.Year && d.Month == dt.Month && d.Day == dt.Day 141 | } 142 | 143 | func (d Date) SQL() string { 144 | return fmt.Sprintf("'%s'", d.String()) 145 | } 146 | 147 | func (d Date) CompareTo(other *Date) int { 148 | if d.Year > other.Year { 149 | return 1 150 | } else if d.Year < other.Year { 151 | return -1 152 | } else if d.Month > other.Month { 153 | return 1 154 | } else if d.Month < other.Month { 155 | return -1 156 | } else if d.Day > other.Day { 157 | return 1 158 | } else if d.Day < other.Day { 159 | return -1 160 | } else { 161 | return 0 162 | } 163 | } 164 | 165 | func BeginningOfMonth() Date { 166 | t := time.Now() 167 | 168 | return NewDate(time.Date(t.Year(), t.Month(), 1, 12, 0, 0, 0, t.Location())) 169 | } 170 | 171 | func EndOfMonth() Date { 172 | t := time.Now() 173 | 174 | return NewDate(time.Date(t.Year(), t.Month(), endOfMonth(int(t.Month()), t.Year()), 12, 0, 0, 0, t.Location())) 175 | } 176 | 177 | func BeginningOfLastMonth() Date { 178 | t := time.Now() 179 | 180 | return NewDate(time.Date(t.Year(), t.Month() - 1, 1, 12, 0, 0, 0, t.Location())) 181 | } 182 | 183 | func EndOfLastMonth() Date { 184 | t := time.Now() 185 | 186 | return NewDate(time.Date(t.Year(), t.Month() - 1, endOfMonth(int(t.Month() - 1), t.Year()), 12, 0, 0, 0, t.Location())) 187 | } 188 | 189 | 190 | func endOfMonth(month, year int) int { 191 | m := map[int]int { 192 | 1: 31, 193 | 3: 31, 194 | 4: 30, 195 | 5: 31, 196 | 6: 30, 197 | 7: 31, 198 | 8: 31, 199 | 9: 30, 200 | 10: 31, 201 | 11: 30, 202 | 12: 31, 203 | } 204 | 205 | if month == 2 && year % 4 == 0 { 206 | return 29 207 | } else if month == 2 { 208 | return 28 209 | } else { 210 | return m[month] 211 | } 212 | } 213 | 214 | func ParseDate(s string) (*Date, error) { 215 | r := regexp.MustCompile("(\\d?\\d)\\.(\\d?\\d)\\.?(\\d?\\d?\\d\\d)?") 216 | 217 | matchData := r.FindStringSubmatch(s) 218 | 219 | var day, month, year int64 220 | var err error 221 | 222 | day, err = strconv.ParseInt(matchData[1], 10, 64) 223 | month, err = strconv.ParseInt(matchData[2], 10, 64) 224 | 225 | 226 | if matchData[3] == "" { 227 | year = int64(time.Now().Year()) 228 | } else { 229 | year, err = strconv.ParseInt(matchData[3], 10, 64) 230 | } 231 | 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | if year < 100 { 237 | year += 2000 238 | } 239 | 240 | t := time.Date(int(year), time.Month(month), int(day), 12, 0, 0, 0, time.Now().Location()) 241 | 242 | d := NewDate(t) 243 | 244 | return &d, nil 245 | } 246 | --------------------------------------------------------------------------------