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) }