gersemi.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. package gersemi
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log"
  8. "net/http"
  9. "regexp"
  10. "strings"
  11. "time"
  12. "apiote.xyz/p/asgard/idavollr"
  13. "apiote.xyz/p/asgard/jotunheim"
  14. "apiote.xyz/p/gott/v2"
  15. _ "github.com/emersion/go-message/charset"
  16. )
  17. type InvalidMessageError struct{}
  18. func (InvalidMessageError) Error() string {
  19. return "message does not match withdrawal or deposit regex"
  20. }
  21. type Transaction interface {
  22. IsTransaction()
  23. }
  24. type TransactionData struct {
  25. Type string `json:"type"`
  26. Date string `json:"date"` //yyyy-mm-ddT00:00:00+00:00
  27. Amount string `json:"amount"`
  28. Description string `json:"description"`
  29. }
  30. type Withdrawal struct {
  31. TransactionData
  32. SourceID string `json:"source_id"`
  33. DestinationName string `json:"destination_name"`
  34. }
  35. func (w Withdrawal) IsTransaction() {}
  36. type Deposit struct {
  37. TransactionData
  38. SourceName string `json:"source_name"`
  39. DestinationID string `json:"destination_id"`
  40. }
  41. func (w Deposit) IsTransaction() {}
  42. type GersemiRequestBody struct {
  43. Transactions []Transaction `json:"transactions"`
  44. }
  45. type GersemiMailbox struct {
  46. idavollr.Mailbox
  47. hc *http.Client
  48. withdrawalRegexes []*regexp.Regexp
  49. depositRegexes []*regexp.Regexp
  50. }
  51. type GersemiImapMessage struct {
  52. idavollr.ImapMessage
  53. hc *http.Client
  54. withdrawalRegexes []*regexp.Regexp
  55. depositRegexes []*regexp.Regexp
  56. config jotunheim.Config
  57. src, dst string
  58. title string
  59. amount string
  60. day, month, year string
  61. requestBody GersemiRequestBody
  62. requestBodyBytes []byte
  63. request *http.Request
  64. response *http.Response
  65. }
  66. func Gersemi(config jotunheim.Config) error {
  67. timeout, _ := time.ParseDuration("60s")
  68. mailbox := &GersemiMailbox{
  69. Mailbox: idavollr.Mailbox{
  70. MboxName: config.Gersemi.ImapInbox,
  71. ImapAdr: config.Gersemi.ImapAddress,
  72. ImapUser: config.Gersemi.ImapUsername,
  73. ImapPass: config.Gersemi.ImapPassword,
  74. Conf: config,
  75. },
  76. hc: &http.Client{
  77. Timeout: timeout,
  78. },
  79. }
  80. mailbox.SetupChannels()
  81. r := gott.R[idavollr.AbstractMailbox]{
  82. S: mailbox,
  83. }.
  84. Bind(prepareRegexes).
  85. Bind(idavollr.Connect).
  86. Tee(idavollr.Login).
  87. Bind(idavollr.SelectInbox).
  88. Tee(idavollr.CheckEmptyBox).
  89. Map(idavollr.FetchMessages).
  90. Tee(createTransactions).
  91. Tee(idavollr.CheckFetchError).
  92. Recover(idavollr.IgnoreEmptyBox).
  93. Recover(idavollr.Disconnect)
  94. return r.E
  95. }
  96. func prepareRegexes(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) {
  97. m := am.(*GersemiMailbox)
  98. for i, wr := range m.Config().Gersemi.WithdrawalRegexes {
  99. re, err := regexp.Compile(wr)
  100. if err != nil {
  101. return m, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err)
  102. }
  103. m.withdrawalRegexes = append(m.withdrawalRegexes, re)
  104. }
  105. for i, dr := range m.Config().Gersemi.DepositRegexes {
  106. re, err := regexp.Compile(dr)
  107. if err != nil {
  108. return m, fmt.Errorf("while compiling deposit regex %d: %w", i, err)
  109. }
  110. m.depositRegexes = append(m.depositRegexes, re)
  111. }
  112. return m, nil
  113. }
  114. func createTransactions(am idavollr.AbstractMailbox) error {
  115. m := am.(*GersemiMailbox)
  116. for msg := range m.Messages() {
  117. imapMessage := idavollr.ImapMessage{
  118. Msg: msg,
  119. Sect: m.Section(),
  120. Mimetype: m.Config().Gersemi.MessageMime,
  121. }
  122. imapMessage.SetClient(m.Client())
  123. r := gott.R[idavollr.AbstractImapMessage]{
  124. S: &GersemiImapMessage{
  125. ImapMessage: imapMessage,
  126. config: m.Config(),
  127. withdrawalRegexes: m.withdrawalRegexes,
  128. depositRegexes: m.depositRegexes,
  129. hc: m.hc,
  130. },
  131. }.
  132. Bind(idavollr.ReadMessageBody).
  133. Bind(idavollr.ParseMimeMessage).
  134. Bind(idavollr.GetBody).
  135. Bind(idavollr.ReadBody).
  136. Bind(createTransaction).
  137. Bind(marshalBody).
  138. Bind(createRequest).
  139. Bind(doRequest).
  140. Bind(handleHttpError).
  141. Tee(moveMessage).
  142. Recover(ignoreInvalidMessage).
  143. Recover(idavollr.RecoverMalformedMessage).
  144. Recover(idavollr.RecoverErroredMessages)
  145. if r.E != nil {
  146. log.Printf("while processing message %s: %v", msg.Envelope.Subject, r.E)
  147. }
  148. }
  149. return nil
  150. }
  151. func matchRegex(m *GersemiImapMessage, match []string, groupNames []string) *GersemiImapMessage {
  152. for groupIdx, group := range match {
  153. names := groupNames[groupIdx]
  154. for _, name := range strings.Split(names, "_") {
  155. switch name {
  156. case "TITLE":
  157. m.title = regexp.MustCompile(" +").ReplaceAllString(group, " ")
  158. case "SRC":
  159. m.src = group
  160. case "DST":
  161. m.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ")
  162. case "AMOUNTC":
  163. m.amount = group
  164. m.amount = strings.Replace(m.amount, ",", ".", -1)
  165. case "AMOUNT":
  166. m.amount = group
  167. case "DAY":
  168. m.day = group
  169. case "MONTH":
  170. m.month = group
  171. case "YEAR":
  172. m.year = group
  173. }
  174. }
  175. }
  176. return m
  177. }
  178. func createWithdrawal(m *GersemiImapMessage) *GersemiImapMessage {
  179. for _, regex := range m.withdrawalRegexes {
  180. groupNames := regex.SubexpNames()
  181. matches := regex.FindAllStringSubmatch(string(m.MessageBody()), -1)
  182. if matches == nil {
  183. continue
  184. }
  185. match := matches[0]
  186. m = matchRegex(m, match, groupNames)
  187. transaction := Withdrawal{
  188. TransactionData: TransactionData{
  189. Type: "withdrawal",
  190. Date: m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
  191. Amount: m.amount,
  192. Description: m.title,
  193. },
  194. SourceID: m.config.Gersemi.Accounts[m.src],
  195. DestinationName: m.dst,
  196. }
  197. body := GersemiRequestBody{
  198. Transactions: []Transaction{transaction},
  199. }
  200. m.requestBody = body
  201. return m
  202. }
  203. return nil
  204. }
  205. func createDeposit(m *GersemiImapMessage) *GersemiImapMessage {
  206. for _, regex := range m.depositRegexes {
  207. groupNames := regex.SubexpNames()
  208. matches := regex.FindAllStringSubmatch(string(m.MessageBody()), -1)
  209. if matches == nil {
  210. continue
  211. }
  212. match := matches[0]
  213. m = matchRegex(m, match, groupNames)
  214. transaction := Deposit{
  215. TransactionData: TransactionData{
  216. Type: "deposit",
  217. Date: m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
  218. Amount: m.amount,
  219. Description: m.title,
  220. },
  221. SourceName: m.src,
  222. DestinationID: m.config.Gersemi.Accounts[m.dst],
  223. }
  224. body := GersemiRequestBody{
  225. Transactions: []Transaction{transaction},
  226. }
  227. m.requestBody = body
  228. return m
  229. }
  230. return nil
  231. }
  232. func createTransaction(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
  233. m := am.(*GersemiImapMessage)
  234. m.src = m.config.Gersemi.DefaultSource
  235. result := createWithdrawal(m)
  236. if result != nil {
  237. return result, nil
  238. }
  239. result = createDeposit(m)
  240. if result != nil {
  241. return result, nil
  242. }
  243. return m, InvalidMessageError{}
  244. }
  245. func marshalBody(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) {
  246. m := am.(*GersemiImapMessage)
  247. m.requestBodyBytes, err = json.Marshal(m.requestBody)
  248. return m, err
  249. }
  250. func createRequest(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) {
  251. m := am.(*GersemiImapMessage)
  252. m.request, err = http.NewRequest("POST", m.config.Gersemi.Firefly+"/api/v1/transactions", bytes.NewReader(m.requestBodyBytes))
  253. m.request.Header.Add("Authorization", "Bearer "+m.config.Gersemi.FireflyToken)
  254. m.request.Header.Add("Accept", "application/vnd.api+json")
  255. m.request.Header.Add("Content-Type", "application/json")
  256. return m, err
  257. }
  258. func doRequest(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) {
  259. m := am.(*GersemiImapMessage)
  260. m.response, err = m.hc.Do(m.request)
  261. return m, err
  262. }
  263. func handleHttpError(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
  264. m := am.(*GersemiImapMessage)
  265. if m.response.StatusCode != 200 {
  266. return m, fmt.Errorf(m.response.Status)
  267. }
  268. return m, nil
  269. }
  270. func moveMessage(am idavollr.AbstractImapMessage) error { // TODO collect messages out of loop and move all
  271. m := am.(*GersemiImapMessage)
  272. return idavollr.MoveMsg(m.Client(), m.Msg, m.config.Gersemi.DoneFolder)
  273. }
  274. func ignoreInvalidMessage(s idavollr.AbstractImapMessage, e error) (idavollr.AbstractImapMessage, error) {
  275. var invalidMessageErr InvalidMessageError
  276. if errors.As(e, &invalidMessageErr) {
  277. log.Println(e.Error())
  278. return s, nil
  279. }
  280. return s, e
  281. }