asgard.git

commit 48411a51e14d4dfbace72997ae77dee55059d426

Author: Adam <git@apiote.xyz>

add gersemi

 config_example.dirty | 19 ++
 gersemi.go | 363 ++++++++++++++++++++++++++++++++++++++++++++++
 imap.go | 13 +
 main.go | 18 ++
 mimir.go | 23 --


diff --git a/config_example.dirty b/config_example.dirty
index ffed9a6928631d65a7eea64aa570b2186336badd..538adc65574227a2fd43e6a3a1ff32d1a820467d 100644
--- a/config_example.dirty
+++ b/config_example.dirty
@@ -57,4 +57,23 @@ `
 			)
 		)
 	)
+	('gersemi'
+		(
+			('fireflyToken' '')
+			('firefly' '')
+			('imapAddress' '')
+			('imapUsername' '')
+			('imapPassword' '')
+			('messageMime' '')
+			('doneFolder' '')
+			('defaultSource' '')
+			('withdrawalRegexes' (''))
+			('depositRegexes' (''))
+			('accounts'
+				(
+					('<IBAN>' '<ID>')
+				)
+			)
+		)
+	)
 )




diff --git a/gersemi.go b/gersemi.go
new file mode 100644
index 0000000000000000000000000000000000000000..fd278c8d0e47466c3fe5c3950fd279e46c2001b3
--- /dev/null
+++ b/gersemi.go
@@ -0,0 +1,363 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"regexp"
+	"strings"
+	"time"
+
+	"apiote.xyz/p/gott/v2"
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap/client"
+	"github.com/emersion/go-message"
+	_ "github.com/emersion/go-message/charset"
+)
+
+type InvalidMessageError struct{}
+
+func (InvalidMessageError) Error() string {
+	return "message does not match withdrawal or deposit regex"
+}
+
+type Transaction interface {
+	IsTransaction()
+}
+
+type TransactionData struct {
+	Type        string `json:"type"`
+	Date        string `json:"date"` //yyyy-mm-ddT00:00:00+00:00
+	Amount      string `json:"amount"`
+	Description string `json:"description"`
+}
+
+type Withdrawal struct {
+	TransactionData
+	SourceID        string `json:"source_id"`
+	DestinationName string `json:"destination_name"`
+}
+
+func (w Withdrawal) IsTransaction() {}
+
+type Deposit struct {
+	TransactionData
+	SourceName    string `json:"source_id"`
+	DestinationID string `json:"destination_name"`
+}
+
+func (w Deposit) IsTransaction() {}
+
+type GersemiRequestBody struct {
+	Transactions []Transaction `json:"transactions"`
+}
+
+type gersemiStruct struct {
+	c      *client.Client
+	config Config
+	hc     *http.Client
+
+	mbox              *imap.MailboxStatus
+	section           *imap.BodySectionName
+	messages          chan *imap.Message
+	done              chan error
+	withdrawalRegexes []*regexp.Regexp
+	depositRegexes    []*regexp.Regexp
+
+	message             *imap.Message
+	messageBytes        []byte
+	mimeMessage         *message.Entity
+	plainTextBodyReader io.Reader
+	plainTextBody       []byte
+	src, dst            string
+	title               string
+	amount              string
+	day, month, year    string
+	requestBody         GersemiRequestBody
+	requestBodyBytes    []byte
+	request             *http.Request
+	response            *http.Response
+}
+
+func gersemi(config Config) error {
+	c, err := client.DialTLS(config.Gersemi.ImapAddress, nil)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	log.Println("Connected")
+	defer c.Close()
+	defer c.Logout()
+	if err := c.Login(config.Gersemi.ImapUsername, config.Gersemi.ImapPassword); err != nil {
+		log.Fatalln(err)
+	}
+	log.Println("Logged in")
+
+	timeout, _ := time.ParseDuration("60s")
+	r := gott.R[gersemiStruct]{
+		S: gersemiStruct{
+			c:      c,
+			config: config,
+			hc: &http.Client{
+				Timeout: timeout,
+			},
+		},
+	}.
+		Bind(prepareRegexes).
+		Bind(selectGersemiInbox).
+		Tee(checkGersemiEmptyBox).
+		Map(fetchGersemiMessages).
+		Bind(createTransactions).
+		Recover(ignoreGersemiEmptyBox)
+
+	return r.E
+}
+
+func prepareRegexes(s gersemiStruct) (gersemiStruct, error) {
+	for i, wr := range s.config.Gersemi.WithdrawalRegexes {
+		re, err := regexp.Compile(wr)
+		if err != nil {
+			return s, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err)
+		}
+		s.withdrawalRegexes = append(s.withdrawalRegexes, re)
+	}
+
+	for i, dr := range s.config.Gersemi.DepositRegexes {
+		re, err := regexp.Compile(dr)
+		if err != nil {
+			return s, fmt.Errorf("while compiling deposit regex %d: %w", i, err)
+		}
+		s.depositRegexes = append(s.depositRegexes, re)
+	}
+
+	return s, nil
+}
+
+func selectGersemiInbox(s gersemiStruct) (gersemiStruct, error) {
+	mbox, err := s.c.Select("INBOX", false)
+	s.mbox = mbox
+	return s, err
+}
+
+func checkGersemiEmptyBox(s gersemiStruct) error {
+	return checkImapEmptyBox(s.mbox)
+}
+
+func fetchGersemiMessages(s gersemiStruct) gersemiStruct { // fixme same as in mimir
+	from := uint32(1)
+	to := s.mbox.Messages
+	seqset := new(imap.SeqSet)
+	seqset.AddRange(from, to)
+
+	s.section = &imap.BodySectionName{}
+	items := []imap.FetchItem{imap.FetchEnvelope, s.section.FetchItem(), imap.FetchUid}
+	s.messages = make(chan *imap.Message, 10)
+	s.done = make(chan error, 1)
+	go func() {
+		s.done <- s.c.Fetch(seqset, items, s.messages)
+	}()
+	return s
+}
+
+func ignoreGersemiEmptyBox(s gersemiStruct, e error) (gersemiStruct, error) {
+	var emptyBoxError EmptyBoxError
+	if errors.As(e, &emptyBoxError) {
+		log.Println("Mailbox is empty")
+		return s, nil
+	}
+	return s, e
+}
+
+func createTransactions(s gersemiStruct) (gersemiStruct, error) {
+	for msg := range s.messages {
+		s.message = msg
+		r := gott.R[gersemiStruct]{S: s}.
+			Bind(readGersemiMsgBody).
+			Bind(parseGersemiMimeMessage).
+			Bind(getGersemiBody).
+			Bind(readGersemiBody).
+			Bind(createTransaction).
+			Bind(marshalBody).
+			Bind(createRequest).
+			Bind(doRequest).
+			Bind(handleHttpError).
+			SafeTee(moveGersemiMessage).
+			Recover(ignoreGersemiInvalidMessage).
+			Recover(recoverGersemiMalformedMessage).
+			Recover(recoverGersemiErroredMessages)
+		if r.E != nil {
+			return s, r.E
+		}
+	}
+	return s, nil
+}
+
+func readGersemiMsgBody(s gersemiStruct) (gersemiStruct, error) {
+	r := s.message.GetBody(s.section)
+	if r == nil {
+		return s, MalformedMessageError{
+			Cause:     errors.New("no body in message"),
+			MessageID: s.message.Envelope.MessageId,
+		}
+	}
+	messageBytes, err := io.ReadAll(r)
+	s.messageBytes = messageBytes
+	return s, err
+}
+
+func parseGersemiMimeMessage(s gersemiStruct) (gersemiStruct, error) {
+	r := bytes.NewReader(s.messageBytes)
+	m, err := message.Read(r)
+	s.mimeMessage = m
+	return s, err
+}
+
+func getGersemiBody(s gersemiStruct) (gersemiStruct, error) {
+	body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId, s.config.Gersemi.MessageMime)
+	s.plainTextBodyReader = body
+	return s, err
+}
+
+func readGersemiBody(s gersemiStruct) (gersemiStruct, error) {
+	body, err := io.ReadAll(s.plainTextBodyReader)
+	s.plainTextBody = body
+	return s, err
+}
+
+func matchRegex(s gersemiStruct, match []string, groupNames []string) gersemiStruct {
+	for groupIdx, group := range match {
+		names := groupNames[groupIdx]
+		for _, name := range strings.Split(names, "_") {
+			switch name {
+			case "TITLE":
+				s.title = regexp.MustCompile(" +").ReplaceAllString(group, " ")
+			case "SRC":
+				s.src = group
+			case "DST":
+				s.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ")
+			case "AMOUNTC":
+				s.amount = group
+				s.amount = strings.Replace(s.amount, ",", ".", -1)
+			case "AMOUNT":
+				s.amount = group
+			case "DAY":
+				s.day = group
+			case "MONTH":
+				s.month = group
+			case "YEAR":
+				s.year = group
+			}
+		}
+	}
+	return s
+}
+
+func createTransaction(s gersemiStruct) (gersemiStruct, error) {
+	s.src = s.config.Gersemi.DefaultSource
+	for _, wr := range s.withdrawalRegexes {
+		groupNames := wr.SubexpNames()
+		matches := wr.FindAllStringSubmatch(string(s.plainTextBody), -1)
+		if matches == nil {
+			continue
+		}
+		match := matches[0]
+		s = matchRegex(s, match, groupNames)
+		transaction := Withdrawal{
+			TransactionData: TransactionData{
+				Type:        "withdrawal",
+				Date:        s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00",
+				Amount:      s.amount,
+				Description: s.title,
+			},
+			SourceID:        s.config.Gersemi.Accounts[s.src],
+			DestinationName: s.dst,
+		}
+		body := GersemiRequestBody{
+			Transactions: []Transaction{transaction},
+		}
+		s.requestBody = body
+		return s, nil
+	}
+	for _, dr := range s.depositRegexes {
+		groupNames := dr.SubexpNames()
+		matches := dr.FindAllStringSubmatch(string(s.plainTextBody), -1)
+		if matches == nil {
+			continue
+		}
+		match := matches[0]
+		s = matchRegex(s, match, groupNames)
+		transaction := Deposit{
+			TransactionData: TransactionData{
+				Type:        "deposit",
+				Date:        s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00",
+				Amount:      s.amount,
+				Description: s.title,
+			},
+			SourceName:    s.src,
+			DestinationID: s.config.Gersemi.Accounts[s.dst],
+		}
+		body := GersemiRequestBody{
+			Transactions: []Transaction{transaction},
+		}
+		s.requestBody = body
+		return s, nil
+	}
+
+	return s, InvalidMessageError{}
+}
+
+func marshalBody(s gersemiStruct) (_ gersemiStruct, err error) {
+	s.requestBodyBytes, err = json.Marshal(s.requestBody)
+	return s, err
+}
+
+func createRequest(s gersemiStruct) (_ gersemiStruct, err error) {
+	s.request, err = http.NewRequest("POST", s.config.Gersemi.Firefly+"/api/v1/transactions", bytes.NewReader(s.requestBodyBytes))
+	s.request.Header.Add("Authorization", "Bearer "+s.config.Gersemi.FireflyToken)
+	s.request.Header.Add("Accept", "application/vnd.api+json")
+	s.request.Header.Add("Content-Type", "application/json")
+	return s, err
+}
+
+func doRequest(s gersemiStruct) (_ gersemiStruct, err error) {
+	s.response, err = s.hc.Do(s.request)
+	return s, err
+}
+
+func handleHttpError(s gersemiStruct) (gersemiStruct, error) {
+	if s.response.StatusCode != 200 {
+		return s, fmt.Errorf(s.response.Status)
+	}
+	return s, nil
+}
+
+func moveGersemiMessage(s gersemiStruct) {
+	moveMsg(s.c, s.message, s.config.Gersemi.DoneFolder)
+}
+
+func ignoreGersemiInvalidMessage(s gersemiStruct, e error) (gersemiStruct, error) {
+	var invalidMessageErr InvalidMessageError
+	if errors.As(e, &invalidMessageErr) {
+		log.Println(e.Error())
+		return s, nil
+	}
+	return s, e
+}
+
+func recoverGersemiMalformedMessage(s gersemiStruct, err error) (gersemiStruct, error) {
+	var malformedMessageError MalformedMessageError
+	if errors.As(err, &malformedMessageError) {
+		err = nil
+		log.Println(malformedMessageError.Error())
+		log.Println(string(s.messageBytes))
+	}
+	return s, err
+}
+
+func recoverGersemiErroredMessages(s gersemiStruct, err error) (gersemiStruct, error) {
+	log.Printf("message %s errored: %v\n", s.message.Envelope.MessageId, err)
+	return s, nil
+}




diff --git a/imap.go b/imap.go
index 9e5bf0c3fff00194f30018b290b2fd086ad6a81c..728babdccd0f8f449b5f86ae3fdf38edbddd3008 100644
--- a/imap.go
+++ b/imap.go
@@ -14,6 +14,12 @@ 	"github.com/emersion/go-sasl"
 	"github.com/emersion/go-smtp"
 )
 
+type EmptyBoxError struct{}
+
+func (EmptyBoxError) Error() string {
+	return ""
+}
+
 func moveMsg(c *client.Client, msg *imap.Message, dest string) {
 	log.Printf("moving %v : %s from %s to %s\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), dest)
 	moveClient := move.NewClient(c)
@@ -143,3 +149,10 @@ 	item := imap.FormatFlagsOp(imap.AddFlags, true)
 	flags := []interface{}{imap.DeletedFlag}
 	return c.UidStore(seqset, item, flags, nil)
 }
+
+func checkImapEmptyBox(mbox *imap.MailboxStatus) error {
+	if mbox.Messages == 0 {
+		return EmptyBoxError{}
+	}
+	return nil
+}




diff --git a/main.go b/main.go
index 5a371aa1b8516c436cb789858fd5cfebde6a15ff..9ae3be702b042e8452699e0f28d891b43055af5c 100644
--- a/main.go
+++ b/main.go
@@ -56,11 +56,26 @@ 	PrivateKey       string
 	PublicKey        string
 }
 
+type GersemiConfig struct {
+	FireflyToken      string
+	Firefly           string
+	ImapAddress       string
+	ImapUsername      string
+	ImapPassword      string
+	MessageMime       string
+	DoneFolder        string
+	DefaultSource     string
+	WithdrawalRegexes []string
+	DepositRegexes    []string
+	Accounts          map[string]string
+}
+
 type Config struct {
 	Tyr     TyrConfig
 	Hermodr HermodrConfig
 	Mimir   MimirConfig
 	Eostre  EostreConfig
+	Gersemi GersemiConfig
 }
 
 func readConfig() (Config, error) {
@@ -135,6 +150,9 @@ 	case "Ä“ostre":
 		fallthrough
 	case "eostre":
 		log.Println(eostre(config))
+
+	case "gersemi":
+		log.Println(gersemi(config))
 
 	case "serve":
 		http.HandleFunc("/tyr", tyr_serve)




diff --git a/mimir.go b/mimir.go
index 451c7dde37fcc3072baf89e7b0321f4c33842474..652b8e972578fa55b7edac0a8c3696e2ae89b65c 100644
--- a/mimir.go
+++ b/mimir.go
@@ -49,12 +49,6 @@ 	_ "github.com/emersion/go-message/charset"
 	"github.com/emersion/go-msgauth/dkim"
 )
 
-type EmptyBoxError struct{}
-
-func (EmptyBoxError) Error() string {
-	return ""
-}
-
 type MalformedMessageError struct {
 	MessageID string
 	Cause     error
@@ -128,10 +122,7 @@ 	return s, err
 }
 
 func checkEmptyBox(s mimirStruct) error {
-	if s.mbox.Messages == 0 {
-		return EmptyBoxError{}
-	}
-	return nil
+	return checkImapEmptyBox(s.mbox)
 }
 
 func selectMimirInbox(s mimirStruct) (mimirStruct, error) {
@@ -214,7 +205,7 @@ 	s.mimeMessage = m
 	return s, err
 }
 
-func getNextPart(message *message.Entity, ID string) (io.Reader, error) {
+func getNextPart(message *message.Entity, ID string, mimetype string) (io.Reader, error) {
 	if mr := message.MultipartReader(); mr != nil {
 		for {
 			p, err := mr.NextPart()
@@ -223,25 +214,25 @@ 				break
 			} else if err != nil {
 				return nil, fmt.Errorf("while reading next part: %w", err)
 			}
-			return getNextPart(p, ID)
+			return getNextPart(p, ID, mimetype)
 		}
 	} else {
 		t, _, err := message.Header.ContentType()
 		if err != nil {
 			return nil, fmt.Errorf("while getting content type: %w", err)
 		}
-		if t == "text/plain" {
+		if t == mimetype {
 			return message.Body, nil
 		}
 	}
 	return nil, MalformedMessageError{
-		Cause:     errors.New("text/plain not found"),
+		Cause:     errors.New(mimetype + " not found"),
 		MessageID: ID,
 	}
 }
 
 func getPlainTextBody(s mimirStruct) (mimirStruct, error) {
-	body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId)
+	body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId, "text/plain")
 	s.plainTextBodyReader = body
 	return s, err
 }
@@ -289,7 +280,7 @@ 	messageID := s.message.Envelope.MessageId
 	inReplyTo := s.message.Envelope.InReplyTo
 	subject := s.message.Envelope.Subject
 	sender := s.message.Envelope.From[0]
-	log.Printf("forwarding %s to %v\n", messageID, s.recipients)  
+	log.Printf("forwarding %s to %v\n", messageID, s.recipients)
 	return forwardMessage(s.config, s.category, messageID, inReplyTo, subject, s.plainTextBody, s.recipients, sender)
 }