Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-transfer #23

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 117 additions & 37 deletions command/transfer_coin.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package command

import (
"errors"
"fmt"
"math/rand"
"strconv"
"strings"
"time"

"github.com/hex337/alex-koin-go/config"
"github.com/hex337/alex-koin-go/model"
Expand All @@ -14,64 +18,140 @@ import (
type TransferCoinCommand struct{}

func (c *TransferCoinCommand) Run(msg string, event *CoinEvent) (string, error) {
re := regexp.MustCompile(`^(?i)transfer (?P<amount>[0-9]+) (?:to )?\<@(?P<to_slack_id>[0-9A-Z]+)\> (?:for)??(?P<reason>.+)`)
matches := re.FindStringSubmatch(event.Message)

if len(matches) < 4 {
return "Invalid transfer format. Expected something like `@Alex Koin transfer 3 to @alexk for being amazing`. See the channel details for command syntax.", nil
parsedResult, err := parseMessage(event.Message)
if err != nil {
return err.Error(), nil
}

amount, err := strconv.Atoi(matches[1])
if err != nil {
log.Printf("INF amount not parsed as int: %s", matches[1])
return "Invalid transfer amount.", nil
// parsing error should be covered in func validateMessage
totalAmount, _ := strconv.Atoi(parsedResult["amount"])
toUserIds := strings.Split(parsedResult["to_slack_ids"], ",")
reason := parsedResult["reason"]
splitAmounts := splitCoins(totalAmount, len(toUserIds))

for i, toUserId := range toUserIds {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to wrap this in a transaction? I'm just worried that if someone spams a few transfers and these are going one at a time, that we end up in a bad state.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, I actually tried to move all the validation part to the beginning of the method, to reduce the possibility of partial transfer, but might be worth to just use a transaction wrapper, will take a look of the support of GORM

amount := splitAmounts[i]
log.Printf("INF transfer coin amt: %d, toUserIds: %s, reason: %s", totalAmount, toUserIds, reason)

toUser, err := model.GetOrCreateUserBySlackID(toUserId)

if err != nil {
log.Printf("Could not find user with slack id %s: %s", toUserId, err.Error())
return "", err
}

canTransfer, msg := canTransferCoin(event.User, toUser, amount)

if !canTransfer {
return msg, nil
}

var coinIds []int
err = config.DB.Table("coins").Select("id").Where("user_id = ?", event.User.ID).Limit(amount).Find(&coinIds).Error
if err != nil {
log.Printf("ERR error fetching coin ids: %s", err)
return "So uh... something went wrong. D'oh.", err
}

err = config.DB.Table("coins").Where("user_id = ? AND id IN ?", event.User.ID, coinIds).UpdateColumn("user_id", toUser.ID).Error
if err != nil {
log.Printf("ERR error updating coins: %s", err)
return "So uh... something went wrong. D'oh.", err
}

transfer := &model.Transaction{
Amount: amount,
Memo: reason,
FromUserID: event.User.ID,
ToUserID: toUser.ID,
}

err = model.CreateTransaction(transfer)

if err != nil {
log.Printf("Could not transfer coin(s) : %s", err.Error())
return "", err
}

log.Printf("Transfered %d koin from %d to %s.", amount, event.User.ID, toUserId)
}
toUserId := matches[2]
reason := matches[3]

log.Printf("INF transfer coin amt: %d, toUserId: %s, reason: %s", amount, toUserId, reason)
return fmt.Sprintf("Transfered %d koin.", totalAmount), nil
}

toUser, err := model.GetOrCreateUserBySlackID(toUserId)
func parseMessage(message string) (map[string]string, error) {
re := regexp.MustCompile(`^(?i)transfer (?P<amount>[0-9]+) (?:to )?(?P<to_slack_ids>\<[@0-9A-Z<> ]+\>) (?:for)?(?P<reason>.+)`)
matches := re.FindStringSubmatch(message)

if err != nil {
log.Printf("Could not find user with slack id %s: %s", toUserId, err.Error())
return "", err
result := make(map[string]string)
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
result[name] = matches[i]
}
}

canTransfer, msg := canTransferCoin(event.User, toUser, amount)
slackIdsRe := regexp.MustCompile(`(<@(?P<to_slack_id>[0-9A-Z]+)>)+`)
slackIdMatches := slackIdsRe.FindAllStringSubmatch(result["to_slack_ids"], -1)
slackIds := make([]string, len(slackIdMatches))

if !canTransfer {
return msg, nil
}
for i, name := range slackIdsRe.SubexpNames() {
if name != "to_slack_id" {
continue
}

var coinIds []int
err = config.DB.Table("coins").Select("id").Where("user_id = ?", event.User.ID).Limit(amount).Find(&coinIds).Error
if err != nil {
log.Printf("ERR error fetching coin ids: %s", err)
return "So uh... something went wrong. D'oh.", err
for j, v := range slackIdMatches {
slackIds[j] = v[i]
}
}
result["to_slack_ids"] = strings.Join(slackIds, ",")

err = config.DB.Table("coins").Where("user_id = ? AND id IN ?", event.User.ID, coinIds).UpdateColumn("user_id", toUser.ID).Error
err := validateMessage(result)
if err != nil {
log.Printf("ERR error updating coins: %s", err)
return "So uh... something went wrong. D'oh.", err
return nil, err
}
return result, nil
}

transfer := &model.Transaction{
Amount: amount,
Memo: reason,
FromUserID: event.User.ID,
ToUserID: toUser.ID,
func validateMessage(parsedResult map[string]string) error {
requiredKeys := []string {
"amount",
"to_slack_ids",
"reason",
}

err = model.CreateTransaction(transfer)
for _, key := range requiredKeys {
value, ok := parsedResult[key]
if !ok || len(value) == 0 {
return errors.New("Invalid transfer format. Expected something like `@Alex Koin transfer 3 to @alexk for being amazing`. See the channel details for command syntax.")
}
}

amount, err := strconv.Atoi(parsedResult["amount"])
if err != nil {
log.Printf("Could not transfer coin(s) : %s", err.Error())
return "", err
log.Printf("INF amount not parsed as int: %s", parsedResult["amount"])
return errors.New("Invalid transfer amount.")
}

numOfReceivers := len(strings.Split(parsedResult["to_slack_ids"], ","))
if amount < numOfReceivers {
log.Printf("amount is not enough for split: amount: %d, receivers: %d", amount, numOfReceivers)
return errors.New(fmt.Sprintf("alex koin does not support fraction"))
}

return fmt.Sprintf("Transfered %d koin.", amount), nil
return nil
}

func splitCoins(amount int, count int) []int {
rand.Seed(time.Now().UnixNano())
coins := make([]int, count)
leftCount := count - 1
for i, _ := range coins {
coins[i] = rand.Intn(amount - leftCount - 1) + 1
leftCount -= 1
amount -= coins[i]
}
coins[rand.Intn(len(coins))] += amount
return coins
}

func canTransferCoin(sender *model.User, receiver *model.User, amount int) (bool, string) {
Expand Down
58 changes: 58 additions & 0 deletions command/transfer_coin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package command

import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
)

func TestParseMessageWithSingleReceiver(t *testing.T) {
numOfCoins := "10"
receiverId := "U000AAA"
reason := "being awesome"

parsedResult, err := parseMessage(fmt.Sprintf("transfer %s to <@%s> %s", numOfCoins, receiverId, reason))
assert.Nil(t, err, "parse without errors")

assert.Equal(t, numOfCoins, parsedResult["amount"], "matches coin")
assert.Equal(t, receiverId, parsedResult["to_slack_ids"], "matches receiverId")
assert.Equal(t, reason, parsedResult["reason"], "matches reason")
}

func TestParseMessageWithMultipleReceivers(t *testing.T) {
numOfCoins := "10"
receiverId := "U000AAA"
secondReceiverId := "U000BBB"
reason := "being awesome"

parsedResult, err := parseMessage(fmt.Sprintf("transfer %s to <@%s> <@%s> %s", numOfCoins, receiverId, secondReceiverId, reason))
assert.Nil(t, err, "parse without errors")

assert.Equal(t, numOfCoins, parsedResult["amount"], "matches coin")
assert.Equal(t, fmt.Sprintf("%s,%s", receiverId, secondReceiverId), parsedResult["to_slack_ids"], "matches receiverId")
assert.Equal(t, reason, parsedResult["reason"], "matches reason")
}

func TestSplitCoinsWithSingleReceiver(t *testing.T) {
coins := splitCoins(5, 3)

sum := 0
for _, coin := range coins {
assert.LessOrEqual(t, coin, 5, "each coin amount is <= max")
sum += coin
}

assert.Equal(t, 5, sum, "amount is the same")
}

func TestSplitCoinsWithMultipleReceivers(t *testing.T) {
coins := splitCoins(5, 1)

sum := 0
for _, coin := range coins {
assert.LessOrEqual(t, 5, coin, "each coin amount is <= max")
sum += coin
}

assert.Equal(t, 5, sum, "amount is the same")
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ require (
github.com/google/uuid v1.0.0
github.com/joho/godotenv v1.3.0
github.com/nleeper/goment v1.4.2
github.com/pkg/errors v0.8.1
github.com/slack-go/slack v0.9.1
github.com/stretchr/testify v1.7.0
gorm.io/driver/postgres v1.1.0
gorm.io/gorm v1.21.11
)
5 changes: 3 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,10 @@ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoH
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tkuchiki/go-timezone v0.2.0 h1:yyZVHtQRVZ+wvlte5HXvSpBkR0dPYnPEIgq9qqAqltk=
github.com/tkuchiki/go-timezone v0.2.0/go.mod h1:b1Ean9v2UXtxSq4TZF0i/TU9NuoWa9hOzOKoGCV2zqY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
Expand Down Expand Up @@ -475,8 +476,8 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.1.0 h1:afBljg7PtJ5lA6YUWluV2+xovIPhS+YiInuL3kUjrbk=
gorm.io/driver/postgres v1.1.0/go.mod h1:hXQIwafeRjJvUm+OMxcFWyswJ/vevcpPLlGocwAwuqw=
Expand Down