├── bots ├── __init__.py ├── controller.py ├── controller_test.py ├── mattermost_bot.py ├── telegram_bot.py └── web_bot.py ├── bean_utils ├── __init__.py ├── vec_query_test.py ├── rag.py ├── vec_query.py └── bean_test.py ├── requirements ├── optional.txt ├── requirements.txt └── full.txt ├── .dockerignore ├── docker ├── entrypoint.sh ├── compose.yaml ├── Dockerfile └── Dockerfile-armv7 ├── example └── basic_record.png ├── frontend ├── dist │ ├── favicon.ico │ ├── beancount.png │ ├── pwa-64x64.png │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ ├── maskable-icon-512x512.png │ ├── apple-touch-icon-180x180.png │ ├── manifest.webmanifest │ ├── sw.js │ ├── beancount.svg │ ├── assets │ │ └── workbox-window.prod.es5-B9K5rw8f.js │ ├── index.html │ └── workbox-e3490c72.js ├── public │ ├── favicon.ico │ ├── beancount.png │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ ├── pwa-64x64.png │ ├── maskable-icon-512x512.png │ ├── apple-touch-icon-180x180.png │ └── beancount.svg ├── tsconfig.json ├── eslint.config.js ├── src │ ├── types.ts │ ├── icons.ts │ ├── i18n.ts │ ├── style.css │ ├── storage.ts │ ├── api.ts │ ├── locales │ │ └── translation.json │ ├── main.ts │ └── ui.ts ├── vite.config.ts ├── manifest.json ├── package.json ├── backup.html └── index.html ├── locale ├── en │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po ├── de_DE │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po ├── es_ES │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po ├── fr_FR │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po ├── ja_JP │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po ├── ko_KR │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po ├── zh_CN │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po ├── zh_TW │ └── LC_MESSAGES │ │ ├── beanbot.mo │ │ └── beanbot.po └── beanbot.pot ├── .gitattributes ├── vec_db ├── __init__.py ├── match.py ├── json_vec_db.py ├── sqlite_vec_db_test.py ├── json_vec_db_test.py └── sqlite_vec_db.py ├── .gitignore ├── conf ├── utils.py ├── i18n_test.py ├── conf_test.py ├── i18n.py ├── __init__.py ├── utils_test.py ├── config_data_test.py └── config_data.py ├── Makefile ├── .github └── workflows │ ├── unit_test.yml │ └── build_docker.yml ├── main.py ├── ruff.toml ├── config.yaml.example ├── README_zh.md └── README.md /bots/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bean_utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/optional.txt: -------------------------------------------------------------------------------- 1 | sqlite-vec==0.1.1 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .ruff_cache 3 | .pytest_cache 4 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | source $VIRTUAL_ENV/bin/activate 3 | exec "$@" 4 | -------------------------------------------------------------------------------- /example/basic_record.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/example/basic_record.png -------------------------------------------------------------------------------- /frontend/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/dist/favicon.ico -------------------------------------------------------------------------------- /frontend/dist/beancount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/dist/beancount.png -------------------------------------------------------------------------------- /frontend/dist/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/dist/pwa-64x64.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/dist/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/dist/pwa-192x192.png -------------------------------------------------------------------------------- /frontend/dist/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/dist/pwa-512x512.png -------------------------------------------------------------------------------- /frontend/public/beancount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/public/beancount.png -------------------------------------------------------------------------------- /frontend/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/public/pwa-192x192.png -------------------------------------------------------------------------------- /frontend/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/public/pwa-512x512.png -------------------------------------------------------------------------------- /frontend/public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/public/pwa-64x64.png -------------------------------------------------------------------------------- /locale/en/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/en/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | beancount==3.1.0 2 | numpy==2.2.2 3 | fava==1.30 4 | requests==2.32.3 5 | pyyaml==6.0.2 6 | -------------------------------------------------------------------------------- /locale/de_DE/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/de_DE/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /locale/es_ES/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/es_ES/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /locale/fr_FR/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/fr_FR/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /locale/ja_JP/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/ja_JP/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /locale/ko_KR/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/ko_KR/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /locale/zh_CN/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/zh_CN/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /locale/zh_TW/LC_MESSAGES/beanbot.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/locale/zh_TW/LC_MESSAGES/beanbot.mo -------------------------------------------------------------------------------- /frontend/dist/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/dist/maskable-icon-512x512.png -------------------------------------------------------------------------------- /frontend/public/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/public/maskable-icon-512x512.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | testdata/example.bean binary 2 | frontend/dist/** binary 3 | frontend/public/** binary 4 | frontend/package-lock.json binary 5 | -------------------------------------------------------------------------------- /frontend/dist/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/dist/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdioA/beancount-bot/HEAD/frontend/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /requirements/full.txt: -------------------------------------------------------------------------------- 1 | beancount==3.1.0 2 | numpy==2.2.2 3 | fava==1.30 4 | requests==2.32.3 5 | pyyaml==6.0.2 6 | python-telegram-bot==21.4 7 | mmpy-bot==2.1.4 8 | bottle==0.13.2 9 | -------------------------------------------------------------------------------- /vec_db/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .sqlite_vec_db import build_db, query_by_embedding 3 | except ImportError: 4 | from .json_vec_db import build_db, query_by_embedding 5 | 6 | 7 | __all__ = ["build_db", "query_by_embedding"] 8 | -------------------------------------------------------------------------------- /docker/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | beancount-bot: 3 | image: ghcr.io/stdioa/beancount-bot 4 | # command: ["telegram", "-c", "/data/ledger/config.yaml"] 5 | volumes: 6 | - .:/data/ledger 7 | ports: [] 8 | restart: on-failure 9 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "virtual:pwa-register": ["./node_modules/vite-plugin-pwa/client.d.ts"] 5 | }, 6 | "resolveJsonModule": true, 7 | "module": "Node16", 8 | "moduleResolution": "node16" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | .ruff_cache 4 | .pytest_cache 5 | __pycache__ 6 | tx_db.* 7 | config.yaml 8 | **/*.po~ 9 | .coverage 10 | coverage.xml 11 | htmlcov 12 | 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | pnpm-debug.log* 20 | lerna-debug.log* 21 | 22 | node_modules 23 | *.local 24 | -------------------------------------------------------------------------------- /conf/utils.py: -------------------------------------------------------------------------------- 1 | def merge_dicts(dict1, dict2): 2 | result = dict1.copy() 3 | 4 | for key, value in dict2.items(): 5 | if key in result and isinstance(result[key], dict) and isinstance(value, dict): 6 | result[key] = merge_dicts(result[key], value) 7 | else: 8 | result[key] = value 9 | return result 10 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | /** @type {import('eslint').Linter.Config[]} */ 7 | export default [ 8 | {files: ["src/**/*.{js,mjs,cjs,ts}"]}, 9 | {languageOptions: { globals: globals.browser }}, 10 | pluginJs.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ]; 13 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | // 类型定义 2 | export type MessageStatus = 'submitted' | 'pending'; 3 | 4 | export interface Message { 5 | id: number; 6 | message: string; 7 | transaction_text: string; 8 | status: MessageStatus; 9 | favorite: boolean; 10 | } 11 | 12 | export interface ErrorMessage { 13 | error: string; 14 | } 15 | 16 | export type ElementConfig = { 17 | classes?: string[]; 18 | attrs?: Record; 19 | events?: Record; 20 | }; -------------------------------------------------------------------------------- /vec_db/match.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | # Currently not used 4 | # def match(query, sentence): 5 | # sentence_seg = {x.strip('"') for x in sentence.split()} 6 | # query_seg = [x.strip('"') for x in query.split()] 7 | # score = sum(1 for x in sentence_seg if x in query_seg) 8 | # return (score + 1) / (len(query_seg) + 1) 9 | 10 | 11 | def calculate_score(txs, sentence): 12 | occ_score = math.log(txs["occurance"] + 1, 50) 13 | return txs["distance"] * occ_score 14 | -------------------------------------------------------------------------------- /frontend/dist/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"Beancount Bot","short_name":"Beancount Bot","description":"Beancount Bot","start_url":"/index.html","display":"standalone","background_color":"#fff","theme_color":"#000000","lang":"en","scope":"/","icons":[{"src":"pwa-64x64.png","sizes":"64x64","type":"image/png"},{"src":"pwa-192x192.png","sizes":"192x192","type":"image/png"},{"src":"pwa-512x512.png","sizes":"512x512","type":"image/png"},{"src":"maskable-icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"},{"src":"beancount.png","sizes":"200x200","type":"image/png","purpose":"any"}]} 2 | -------------------------------------------------------------------------------- /conf/i18n_test.py: -------------------------------------------------------------------------------- 1 | from conf.conf_test import load_config_from_dict 2 | from conf.i18n import init_locale, gettext as _ 3 | 4 | 5 | def test_default_locale(monkeypatch): 6 | monkeypatch.setenv("LANG", "zh_CN.UTF-8") 7 | load_config_from_dict({}) 8 | init_locale() 9 | assert _("Position") == "持仓" 10 | 11 | 12 | def test_override_locale_with_config(monkeypatch): 13 | monkeypatch.setenv("LANG", "zh_CN.UTF-8") 14 | load_config_from_dict({ 15 | "language": "fr_FR", 16 | }) 17 | init_locale() 18 | assert _("Query account changes") == "Interroger les changements de compte" 19 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim 2 | LABEL org.opencontainers.image.source=https://github.com/StdioA/beancount-bot 3 | 4 | RUN pip install --upgrade uv && mkdir -p /app 5 | WORKDIR /app 6 | ENV VIRTUAL_ENV=/packages/.venv 7 | COPY requirements/full.txt requirements/optional.txt /app/ 8 | RUN mkdir -p $VIRTUAL_ENV && uv venv $VIRTUAL_ENV && \ 9 | uv pip install --no-cache-dir -r full.txt && \ 10 | (uv pip install --no-cache-dir -r optional.txt || true) 11 | ADD . /app 12 | 13 | VOLUME /data/ledger 14 | WORKDIR /data/ledger 15 | ENTRYPOINT ["/app/docker/entrypoint.sh", "python", "/app/main.py"] 16 | CMD ["telegram"] 17 | -------------------------------------------------------------------------------- /conf/conf_test.py: -------------------------------------------------------------------------------- 1 | import conf 2 | from conf.config_data import Config 3 | 4 | 5 | class MutableConfig(Config): 6 | def __setitem__(self, key, value): 7 | self._config[key] = value 8 | 9 | def update(self, *args, **kwargs): 10 | self._config.update(*args, **kwargs) 11 | 12 | @classmethod 13 | def from_dict(cls, dictionary): 14 | config = cls.__new__(cls) 15 | config._config = dictionary 16 | return config 17 | 18 | 19 | def load_config_from_dict(config_dict): 20 | conf.config = MutableConfig.from_dict(config_dict) 21 | return conf.config 22 | 23 | 24 | def clear_config(): 25 | conf.config = None 26 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import tailwindcss from '@tailwindcss/vite' 3 | import { VitePWA, ManifestOptions } from 'vite-plugin-pwa'; 4 | import manifest from './manifest.json'; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | tailwindcss(), 9 | VitePWA({ 10 | includeAssets: ["apple-touch-icon-180x180.png","beancount.svg","maskable-icon-512x512.png","pwa-512x512.png", "beancount.png","favicon.ico","pwa-192x192.png","pwa-64x64.png"], 11 | registerType: 'prompt', 12 | injectRegister: 'auto', 13 | manifest: manifest as ManifestOptions, 14 | workbox: { 15 | globPatterns: ['*.{js,css,html}'], 16 | }, 17 | }), 18 | ], 19 | build: { 20 | sourcemap: false, 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /frontend/src/icons.ts: -------------------------------------------------------------------------------- 1 | // 导入FontAwesome核心库 2 | import { library, dom } from '@fortawesome/fontawesome-svg-core'; 3 | 4 | // 只导入我们需要的图标 5 | import { IconLookup, faTrash, faHistory, faStar, faTimes, faCheck, faCopy, faReceipt, faPaperPlane, faPen } from '@fortawesome/free-solid-svg-icons'; 6 | 7 | // 将图标添加到库中 8 | library.add(faTrash, faHistory, faStar, faTimes, faCheck, faCopy, faReceipt, faPaperPlane, faPen); 9 | 10 | // 自动替换页面上的图标元素 11 | dom.watch(); 12 | 13 | export function buildIconDom(icon: IconLookup): HTMLElement { 14 | const iconElement = document.createElement('i'); 15 | iconElement.classList.add(icon.prefix, `fa-${icon.iconName}`); 16 | return iconElement; 17 | } 18 | 19 | // 导出图标以便在其他地方使用 20 | export { faTrash, faHistory, faStar, faTimes, faCheck, faCopy, faReceipt, faPaperPlane, faPen }; 21 | -------------------------------------------------------------------------------- /frontend/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Beancount Bot", 3 | "short_name": "Beancount Bot", 4 | "description": "Beancount Bot", 5 | "start_url": "/index.html", 6 | "display": "standalone", 7 | "background_color": "#fff", 8 | "theme_color": "#000000", 9 | "icons": [ 10 | { 11 | "src": "pwa-64x64.png", 12 | "sizes": "64x64", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "pwa-192x192.png", 17 | "sizes": "192x192", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "pwa-512x512.png", 22 | "sizes": "512x512", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "maskable-icon-512x512.png", 27 | "sizes": "512x512", 28 | "type": "image/png", 29 | "purpose": "maskable" 30 | }, 31 | { 32 | "src": "beancount.png", 33 | "sizes": "200x200", 34 | "type": "image/png", 35 | "purpose": "any" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /conf/i18n.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import gettext as _gettext 3 | 4 | 5 | _DOMAIN = 'beanbot' 6 | _proxied_gettext = _gettext.gettext 7 | 8 | 9 | def gettext(message): 10 | """ 11 | Translate the given message. 12 | A proxy for gettext. This machanism is used to prevent early import. 13 | """ 14 | return _proxied_gettext(message) 15 | 16 | 17 | def init_locale(): 18 | """Initialize the locale translation.""" 19 | from . import config 20 | locale_dir = Path(__file__).parent.parent / 'locale' 21 | language = config.get('language') 22 | if language is not None: 23 | # Use custom translation 24 | translation = _gettext.translation(_DOMAIN, locale_dir, [language], fallback=True) 25 | global _proxied_gettext 26 | _proxied_gettext = translation.gettext 27 | else: 28 | # Use default translation, and load locale from env 29 | _gettext.bindtextdomain(_DOMAIN, locale_dir) 30 | _gettext.textdomain(_DOMAIN) 31 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "watch": "vite build --watch", 10 | "preview": "vite preview", 11 | "logo": "pwa-assets-generator --preset minimal-2023 public/beancount.svg", 12 | "lint": "eslint src" 13 | }, 14 | "devDependencies": { 15 | "@eslint/js": "^9.20.0", 16 | "@vite-pwa/assets-generator": "^0.2.6", 17 | "eslint": "^9.20.0", 18 | "globals": "^15.14.0", 19 | "tailwindcss": "^4.0.1", 20 | "typescript-eslint": "^8.24.0", 21 | "vite": "^6.0.5", 22 | "vite-plugin-pwa": "^0.21.1" 23 | }, 24 | "dependencies": { 25 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 26 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 27 | "@tailwindcss/postcss": "^4.0.1", 28 | "@tailwindcss/vite": "^4.0.1", 29 | "i18next": "^24.2.2", 30 | "i18next-browser-languagedetector": "^8.0.2", 31 | "loc-i18next": "^0.1.6" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18next from 'i18next'; 2 | import locI18next from 'loc-i18next'; 3 | // import LngDetector from 'i18next-browser-languagedetector'; 4 | import translation from './locales/translation.json'; 5 | 6 | const API_CLONE = '/api/config'; 7 | 8 | export async function initLocale(): Promise { 9 | const language = localStorage.getItem('i18nextLng'); 10 | i18next.init({ 11 | // debug: true, 12 | lng: language, 13 | resources: translation, 14 | }); 15 | const localize = locI18next.init(i18next); 16 | localize("#app"); 17 | 18 | // Async refresh language 19 | try { 20 | const response = await fetch(API_CLONE); 21 | const data = await response.json(); 22 | const targetLanguage = data.lang; 23 | if (targetLanguage !== language) { 24 | localStorage.setItem('i18nextLng', language); 25 | i18next.changeLanguage(targetLanguage) 26 | localize("#app"); 27 | } 28 | } catch (error) { 29 | console.error('Error fetching language:', error); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LANGUAGES := en zh_CN zh_TW fr_FR ja_JP ko_KR de_DE es_ES 2 | 3 | DOMAIN := beanbot 4 | POT_FILE := locale/$(DOMAIN).pot 5 | PO_FILES := $(foreach lang,$(LANGUAGES),locale/$(lang)/LC_MESSAGES/$(DOMAIN).po) 6 | MO_FILES := $(foreach lang,$(LANGUAGES),locale/$(lang)/LC_MESSAGES/$(DOMAIN).mo) 7 | 8 | .PHONY: all gentranslations compiletranslations clean lint 9 | 10 | all: gentranslations compiletranslations 11 | 12 | gentranslations: $(PO_FILES) 13 | 14 | compiletranslations: $(MO_FILES) 15 | 16 | $(POT_FILE): **/*.py 17 | xgettext -d $(DOMAIN) -o $@ $^ 18 | 19 | define po_rule 20 | locale/$(1)/LC_MESSAGES/$(DOMAIN).po: $(POT_FILE) 21 | @mkdir -p $$(dir $$@) 22 | @if [ ! -f $$@ ]; then \ 23 | msginit -i $$< -o $$@ -l $(1); \ 24 | else \ 25 | msgmerge --update $$@ $$<; \ 26 | fi 27 | endef 28 | 29 | $(foreach lang,$(LANGUAGES),$(eval $(call po_rule,$(lang)))) 30 | 31 | %.mo: %.po 32 | msgfmt -o $@ $^ 33 | 34 | # clean: 35 | # rm -f $(POT_FILE) $(PO_FILES) $(MO_FILES) 36 | 37 | lint: 38 | @ruff check 39 | 40 | test: 41 | coverage run --source=. --omit="**/*_test.py,bots/*_bot.py,main.py,test.py" -m pytest 42 | coverage report 43 | @coverage html 44 | -------------------------------------------------------------------------------- /conf/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | from .i18n import init_locale 4 | from .config_data import Config 5 | from .utils import merge_dicts 6 | 7 | 8 | __all__ = ['config', 'init_locale', "load_config", "logger", "init_logging"] 9 | 10 | config = None 11 | 12 | _logger_name = "beanbot" 13 | logger = logging.getLogger(_logger_name) 14 | 15 | 16 | def load_config(config_path): 17 | global config 18 | config = Config(config_path) 19 | 20 | 21 | _default_logging_config = { 22 | "version": 1, 23 | "formatters": { 24 | "standard": { 25 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 26 | } 27 | }, 28 | "handlers": { 29 | "console": { 30 | "class": "logging.StreamHandler", 31 | "level": "WARNING", 32 | "formatter": "standard", 33 | "stream": "ext://sys.stdout" 34 | } 35 | }, 36 | "loggers": { 37 | _logger_name: { 38 | "level": "WARNING", 39 | "handlers": ["console"], 40 | "propagate": False, 41 | } 42 | } 43 | } 44 | 45 | 46 | def init_logging(): 47 | logging_conf = merge_dicts(_default_logging_config, config.get("logging", {})) 48 | logging.config.dictConfig(logging_conf) 49 | -------------------------------------------------------------------------------- /.github/workflows/unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - feature/unit_test 8 | - fix/cc_action 9 | paths-ignore: 10 | - 'README.md' 11 | - 'README_zh.md' 12 | - 'Makefile' 13 | - 'ruff.toml' 14 | - 'docker' 15 | workflow_dispatch: 16 | 17 | jobs: 18 | main: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.x' 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install "pip<24.1" 30 | pip install -r requirements/full.txt 31 | pip install "sqlite-vec==0.1.1" 32 | pip install pytest pytest-cov coverage codecov 33 | - name: Test with pytest 34 | run: pytest --cov 35 | - name: Upload results to Codecov 36 | uses: codecov/codecov-action@v4 37 | with: 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | - name: Export coverage data as LCOV 40 | run: coverage lcov -o coverage.lcov 41 | - name: Upload coverage data to Qlty Cloud 42 | uses: qltysh/qlty-action/coverage@v1 43 | with: 44 | token: ${{ secrets.QLTY_COVERAGE_TOKEN }} 45 | files: "coverage.lcov" 46 | -------------------------------------------------------------------------------- /vec_db/json_vec_db.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import json 3 | from operator import itemgetter 4 | import numpy as np 5 | from numpy.linalg import norm 6 | from vec_db.match import calculate_score 7 | import conf 8 | 9 | 10 | def _get_db_name(): 11 | DB_NAME = "tx_db.json" 12 | db_dir = conf.config.embedding.get("db_store_folder", ".") 13 | return pathlib.Path(db_dir) / DB_NAME 14 | 15 | 16 | def build_db(transactions): 17 | with open(_get_db_name(), "w") as f: 18 | json.dump(transactions, f) 19 | 20 | 21 | def query_by_embedding(embedding, sentence, candidate_amount): 22 | try: 23 | with open(_get_db_name()) as f: 24 | transactions = json.load(f) 25 | except FileNotFoundError: 26 | conf.logger.warning("JSON vector database is not built") 27 | return None 28 | embed_query = np.array(embedding) 29 | # Calculate cosine similarity 30 | for txs in transactions: 31 | embed_tx = np.array(txs["embedding"]) 32 | txs["distance"] = np.dot(embed_tx, embed_query) / (norm(embed_tx) * norm(embed_query)) 33 | txs["score"] = calculate_score(txs, sentence) 34 | transactions.sort(key=itemgetter("distance"), reverse=True) 35 | candidates = transactions[:candidate_amount] 36 | candidates.sort(key=lambda x: x["distance"], reverse=True) 37 | return candidates 38 | -------------------------------------------------------------------------------- /conf/utils_test.py: -------------------------------------------------------------------------------- 1 | from conf.utils import merge_dicts 2 | 3 | 4 | def test_merge_empty_dicts(): 5 | dict1 = {} 6 | dict2 = {} 7 | expected = {} 8 | assert merge_dicts(dict1, dict2) == expected 9 | 10 | def test_merge_dict_with_empty_dict(): 11 | dict1 = {'a': 1, 'b': 2} 12 | dict2 = {} 13 | expected = {'a': 1, 'b': 2} 14 | assert merge_dicts(dict1, dict2) == expected 15 | 16 | def test_merge_non_overlapping_keys(): 17 | dict1 = {'a': 1, 'b': 2} 18 | dict2 = {'c': 3, 'd': 4} 19 | expected = {'a': 1, 'b': 2, 'c': 3, 'd': 4} 20 | assert merge_dicts(dict1, dict2) == expected 21 | 22 | def test_merge_overlapping_keys_non_dict_values(): 23 | dict1 = {'a': 1, 'b': 2} 24 | dict2 = {'b': 3, 'c': 4} 25 | expected = {'a': 1, 'b': 3, 'c': 4} 26 | assert merge_dicts(dict1, dict2) == expected 27 | 28 | def test_merge_overlapping_keys_dict_values(): 29 | dict1 = {'a': 1, 'b': {'x': 1, 'y': 2}} 30 | dict2 = {'b': {'y': 3, 'z': 4}} 31 | expected = {'a': 1, 'b': {'x': 1, 'y': 3, 'z': 4}} 32 | assert merge_dicts(dict1, dict2) == expected 33 | 34 | def test_merge_deeply_nested_dict_values(): 35 | dict1 = {'a': 1, 'b': {'x': 1, 'y': {'m': 1, 'n': 2}}} 36 | dict2 = {'b': {'y': {'n': 3, 'o': 4}}} 37 | expected = {'a': 1, 'b': {'x': 1, 'y': {'m': 1, 'n': 3, 'o': 4}}} 38 | assert merge_dicts(dict1, dict2) == expected 39 | -------------------------------------------------------------------------------- /docker/Dockerfile-armv7: -------------------------------------------------------------------------------- 1 | # # Build beancount from source due to wheel for arm/v7 is not provided 2 | FROM python:3.11 AS builder 3 | 4 | RUN apt-get update -q && apt-get install -y -q python3-lxml python3-numpy cmake bison flex && \ 5 | pip install --upgrade uv && \ 6 | apt-get clean && \ 7 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 8 | WORKDIR /app 9 | ENV VIRTUAL_ENV=/packages/.venv 10 | COPY requirements/full.txt requirements/optional.txt /app/ 11 | RUN mkdir -p $VIRTUAL_ENV && uv venv $VIRTUAL_ENV 12 | RUN grep -E "beancount|mmpy-bot" full.txt | xargs uv pip install --no-cache-dir 13 | RUN uv pip install --extra-index-url=https://www.piwheels.org/simple --no-cache-dir -r full.txt 14 | 15 | FROM python:3.11-slim 16 | LABEL org.opencontainers.image.source=https://github.com/StdioA/beancount-bot 17 | 18 | RUN pip install --upgrade uv && mkdir -p /app 19 | WORKDIR /app 20 | ENV VIRTUAL_ENV=/packages/.venv 21 | RUN mkdir -p $VIRTUAL_ENV && uv venv $VIRTUAL_ENV 22 | COPY requirements/full.txt requirements/optional.txt /app/ 23 | COPY --from=builder /packages/.venv/lib/ $VIRTUAL_ENV/lib/ 24 | RUN grep -E "beancount|mmpy-bot" full.txt | xargs uv pip install --no-cache-dir 25 | RUN uv pip install --extra-index-url=https://www.piwheels.org/simple --no-cache-dir -r full.txt 26 | ADD . /app 27 | 28 | VOLUME /data/ledger 29 | WORKDIR /data/ledger 30 | ENTRYPOINT ["/app/docker/entrypoint.sh", "python", "/app/main.py"] 31 | CMD ["telegram"] 32 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import conf 3 | from bean_utils import bean 4 | 5 | 6 | def init_bot(config_path): 7 | conf.load_config(config_path) 8 | # Init i18n 9 | conf.init_locale() 10 | # Init logging 11 | conf.init_logging() 12 | # Init beancount manager 13 | bean.init_bean_manager() 14 | 15 | 16 | def parse_args(): 17 | """Parse command line arguments.""" 18 | parser = argparse.ArgumentParser( 19 | prog="beanbot", 20 | description="Bot to translate text into beancount transaction", 21 | ) 22 | subparsers = parser.add_subparsers(title="sub command", required=True, dest="command") 23 | 24 | telegram_parser = subparsers.add_parser("telegram") 25 | mattermost_parser = subparsers.add_parser("mattermost") 26 | web_parser = subparsers.add_parser("web") 27 | 28 | for p in [telegram_parser, mattermost_parser, web_parser]: 29 | p.add_argument("-c", type=str, default="config.yaml", help="config file path") 30 | 31 | return parser.parse_args() 32 | 33 | 34 | def main(): 35 | args = parse_args() 36 | init_bot(args.c) 37 | 38 | if args.command == "telegram": 39 | from bots.telegram_bot import run_bot 40 | elif args.command == "mattermost": 41 | from bots.mattermost_bot import run_bot 42 | elif args.command == "web": 43 | from bots.web_bot import run_bot 44 | run_bot() 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /conf/config_data_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import yaml 3 | from conf.config_data import Config, ImmutableDict 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("method", "args", "kwargs"), 8 | [ 9 | ("__setitem__", ["key", "value"], {}), 10 | ("__delitem__", ["key"], {}), 11 | ("update", [["key", "value"]], {}), 12 | ("clear", [], {}), 13 | ("pop", ["key"], {}), 14 | ("popitem", [], {}), 15 | ("setdefault", ["key", "value"], {}), 16 | ], 17 | ) 18 | def test_immutable_dict(method, args, kwargs): 19 | immutable_dict = ImmutableDict({"key": "value"}) 20 | 21 | with pytest.raises(TypeError): 22 | getattr(immutable_dict, method)(*args, **kwargs) 23 | 24 | 25 | def test_config(tmp_path): 26 | conf_data = { 27 | "embedding": { 28 | "enable": False, 29 | "db_store_folder": str(tmp_path), 30 | }, 31 | "beancount": { 32 | "filename": "testdata/example.bean", 33 | "currency": "USD", 34 | "account_distinguation_range": [2, 3], 35 | } 36 | } 37 | config_path = tmp_path / "config.yaml" 38 | with open(config_path, 'w') as file: 39 | yaml.dump(conf_data, file) 40 | config = Config(str(config_path)) 41 | 42 | assert config 43 | assert config.embedding.enable is False 44 | assert config.beancount.filename == "testdata/example.bean" 45 | assert config.beancount.get("whatever") is None 46 | -------------------------------------------------------------------------------- /frontend/dist/sw.js: -------------------------------------------------------------------------------- 1 | if(!self.define){let e,n={};const i=(i,r)=>(i=new URL(i+".js",r).href,n[i]||new Promise(n=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=n,document.head.appendChild(e)}else e=i,importScripts(i),n()}).then(()=>{let e=n[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(r,o)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(n[c])return;let s={};const d=e=>i(e,c),b={module:{uri:c},exports:s,require:d};n[c]=Promise.all(r.map(e=>b[e]||d(e))).then(e=>(o(...e),s))}}define(["./workbox-e3490c72"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.precacheAndRoute([{url:"index.html",revision:"79d840d5bedbdd120067f6aa86ee5299"},{url:"apple-touch-icon-180x180.png",revision:"7c9b0a267af759013a906664fa9d03b0"},{url:"beancount.png",revision:"02ccb93b8127fb9dfbf9453345af5348"},{url:"beancount.svg",revision:"b02c8cab03c4369314156c492571b967"},{url:"favicon.ico",revision:"5b160691426476930d1aacd9b1134850"},{url:"maskable-icon-512x512.png",revision:"44e4d9c9bbc4f6869c657b67307c85df"},{url:"pwa-192x192.png",revision:"96b09dfedd5f357b2716b29a51eb5d6e"},{url:"pwa-512x512.png",revision:"273328f5dd1cb0a2bfa2965e49ed2f7a"},{url:"pwa-64x64.png",revision:"68bcbac8dae96145b4142dbb7ed31b99"},{url:"manifest.webmanifest",revision:"db03e5489c63e20f216522429c3f1a32"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))}); 2 | -------------------------------------------------------------------------------- /conf/config_data.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | 4 | _ImmutableError = TypeError("This dictionary is immutable") 5 | 6 | class ImmutableDict(dict): 7 | def __setitem__(self, key, value): 8 | raise _ImmutableError 9 | 10 | def __delitem__(self, key): 11 | raise _ImmutableError 12 | 13 | def update(self, *args, **kwargs): 14 | raise _ImmutableError 15 | 16 | def clear(self): 17 | raise _ImmutableError 18 | 19 | def pop(self, *args): 20 | raise _ImmutableError 21 | 22 | def popitem(self): 23 | raise _ImmutableError 24 | 25 | def setdefault(self, *args): 26 | raise _ImmutableError 27 | 28 | 29 | class Config: 30 | def __init__(self, config_path): 31 | with open(config_path, 'r') as file: 32 | self._config = ImmutableDict(yaml.safe_load(file)) 33 | 34 | def __bool__(self): 35 | return bool(self._config) 36 | 37 | def get(self, key, default=None): 38 | return self._config.get(key, default) 39 | 40 | def __getattr__(self, key): 41 | if key in self._config: 42 | value = self._config[key] 43 | if isinstance(value, dict): 44 | return self.__class__.from_dict(value) 45 | return value 46 | # raise AttributeError(f"Config has no attribute '{key}'") 47 | return self.__class__.from_dict({}) 48 | 49 | @classmethod 50 | def from_dict(cls, dictionary): 51 | config = cls.__new__(cls) 52 | config._config = ImmutableDict(dictionary) # noqa: SLF001 53 | return config 54 | -------------------------------------------------------------------------------- /frontend/src/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | /* 全局过渡效果 */ 4 | * { 5 | transition: all 0.2s ease-in-out; 6 | } 7 | 8 | /* 加载动画优化 */ 9 | .spinner { 10 | border: 3px solid rgba(0, 0, 0, 0.1); 11 | width: 40px; 12 | height: 40px; 13 | border-radius: 50%; 14 | border-left-color: #3b82f6; 15 | animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite; 16 | } 17 | 18 | @keyframes spin { 19 | 0% { transform: rotate(0deg); } 20 | 100% { transform: rotate(360deg); } 21 | } 22 | 23 | /* 消息卡片悬浮效果 */ 24 | .message-container { 25 | transition: transform 0.2s, box-shadow 0.2s; 26 | } 27 | 28 | .message-container:hover { 29 | transform: translateY(-2px); 30 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 31 | } 32 | 33 | /* 按钮点击效果 */ 34 | button { 35 | transition: transform 0.1s; 36 | } 37 | 38 | button:active { 39 | transform: scale(0.98); 40 | } 41 | 42 | /* 输入框焦点效果 */ 43 | input:focus { 44 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); 45 | } 46 | 47 | /* 添加触摸设备上的收藏按钮动画 */ 48 | @media (hover: none) { 49 | .ele-collect:active { 50 | animation: touchScale 0.4s ease-out; 51 | } 52 | } 53 | 54 | @keyframes touchScale { 55 | 0% { transform: scale(1); } 56 | 50% { transform: scale(1.2); } 57 | 100% { transform: scale(1); } 58 | } 59 | 60 | /* 对话框动画 */ 61 | #transaction-dialog:not(.hidden) { 62 | animation: slideIn 0.3s ease-out; 63 | } 64 | 65 | @keyframes slideIn { 66 | from { 67 | opacity: 0; 68 | transform: translateY(-10px); 69 | } 70 | to { 71 | opacity: 1; 72 | transform: translateY(0); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /bean_utils/vec_query_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bean_utils import vec_query 3 | from conf.conf_test import load_config_from_dict, clear_config 4 | from conf.config_data import Config 5 | from beancount.parser import parser 6 | from bean_utils.bean import parse_args 7 | 8 | 9 | @pytest.fixture 10 | def mock_config(): 11 | conf_data = { 12 | "beancount": { 13 | "account_distinguation_range": [1, 2], 14 | }, 15 | } 16 | config = load_config_from_dict(conf_data) 17 | yield config 18 | clear_config() 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ("account", "arg", "exp"), 23 | [ 24 | ("Assets:BoFA:Checking", 1, "BoFA"), 25 | ("Assets:BoFA:Checking", [1, 1], "BoFA"), 26 | ("Assets:BoFA:Checking", [1, 2], "BoFA:Checking"), 27 | ("Assets:BoFA:Checking", [1, 5], "BoFA:Checking"), 28 | ], 29 | ) 30 | def test_convert_account(account, arg, exp, mock_config, monkeypatch): 31 | mock_config["beancount"] = { 32 | "account_distinguation_range": arg, 33 | } 34 | assert vec_query.convert_account(account) == exp 35 | 36 | 37 | def test_convert_to_natual_language(monkeypatch, mock_config): 38 | trx_str = """ 39 | 2022-01-01 * "Discount 'abc'" "Discount" 40 | Assets:US:BofA:Checking 4264.93 USD 41 | Equity:Opening-Balances -4264.93 USD 42 | """ 43 | mock_config["beancount"] = { 44 | "account_distinguation_range": [1, 2], 45 | } 46 | trx, _, _ = parser.parse_string(trx_str) 47 | 48 | result = vec_query.convert_to_natural_language(trx[0]) 49 | assert result == '"Discount \'abc\'" "Discount" US:BofA Opening-Balances' 50 | args = parse_args(result) 51 | assert args == ["Discount 'abc'", "Discount", "US:BofA", "Opening-Balances"] 52 | -------------------------------------------------------------------------------- /frontend/src/storage.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './types.js'; 2 | 3 | // 全局消息存储 4 | export const messageStorage: Map = new Map(); 5 | 6 | // DOM 元素获取 7 | export const messageHistory = document.getElementById('message-history') as HTMLElement; 8 | export const messageFavorites = document.getElementById('message-favorites') as HTMLElement; 9 | export const messageInput = document.getElementById('message-input') as HTMLInputElement; 10 | export const sendButton = document.getElementById('send-button') as HTMLButtonElement; 11 | export const transactionDialog = document.getElementById('transaction-dialog') as HTMLElement; 12 | export const transactionText = document.getElementById('transaction-text') as HTMLElement; 13 | export const submitTransactionButton = document.getElementById('submit-transaction') as HTMLButtonElement; 14 | export const cloneTransactionButton = document.getElementById('clone-transaction') as HTMLButtonElement; 15 | export const closeDialogButton = document.getElementById('close-dialog') as HTMLButtonElement; 16 | export const errorDialog = document.getElementById('error-dialog') as HTMLElement; 17 | export const loadingIndicator = document.getElementById('loading-indicator') as HTMLElement; 18 | 19 | // 数字键盘相关元素 20 | export const modifyAmountButton = document.getElementById('modify-amount') as HTMLButtonElement; 21 | export const submitWithAmountButton = document.getElementById('submit-with-amount') as HTMLButtonElement; 22 | export const numpadContainer = document.getElementById('numpad-container') as HTMLElement; 23 | export const amountDisplay = document.getElementById('amount-display') as HTMLElement; 24 | export const numpadClearButton = document.getElementById('numpad-clear') as HTMLButtonElement; 25 | export const numpadButtons = document.querySelectorAll('.numpad-btn') as NodeListOf; 26 | 27 | export const slidingElements = new Set(); -------------------------------------------------------------------------------- /vec_db/sqlite_vec_db_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from vec_db.json_vec_db_test import easy_embedding, mock_config 3 | 4 | try: 5 | import sqlite_vec 6 | except ImportError: 7 | pytest.skip("skipping module tests due to sqlite_vec not installed", allow_module_level=True) 8 | else: 9 | from vec_db import sqlite_vec_db 10 | 11 | 12 | def test_sqlite_db(tmp_path, mock_config, monkeypatch): 13 | monkeypatch.setattr(sqlite_vec_db, "_db", None) 14 | # Build DB 15 | txs = [ 16 | { 17 | "hash": "hash-1", 18 | "occurance": 1, 19 | "sentence": "sentence-1", 20 | "content": "content-1", 21 | "embedding": easy_embedding("content-1"), 22 | }, 23 | { 24 | "hash": "hash-2", 25 | "occurance": 1, 26 | "sentence": "sentence-2", 27 | "content": "content-2", 28 | "embedding": easy_embedding("content-2"), 29 | }, 30 | { 31 | "hash": "hash-3", 32 | "occurance": 1, 33 | "sentence": "sentence-3", 34 | "content": "another-3", 35 | "embedding": easy_embedding("another-3"), 36 | }, 37 | ] 38 | # Query without db built 39 | candidates = sqlite_vec_db.query_by_embedding( 40 | easy_embedding("content-1"), "sentence-1", 2, 41 | ) 42 | assert len(candidates) == 0 43 | # Query with empty table 44 | sqlite_vec_db.build_db([]) 45 | candidates = sqlite_vec_db.query_by_embedding( 46 | easy_embedding("content-1"), "sentence-1", 2, 47 | ) 48 | assert len(candidates) == 0 49 | # Build DB 50 | sqlite_vec_db.build_db(txs) 51 | db_path = sqlite_vec_db._get_db_name() 52 | assert db_path.exists() 53 | # Query DB 54 | candidates = sqlite_vec_db.query_by_embedding( 55 | easy_embedding("content-1"), "sentence-1", 2, 56 | ) 57 | assert len(candidates) == 2 58 | assert candidates[0]["content"] == "content-1" 59 | assert candidates[1]["content"] == "content-2" 60 | # Cleanup 61 | db_path.unlink() 62 | -------------------------------------------------------------------------------- /vec_db/json_vec_db_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typing import List 3 | from conf.conf_test import load_config_from_dict, clear_config 4 | from vec_db import json_vec_db 5 | 6 | 7 | @pytest.fixture 8 | def mock_config(tmp_path): 9 | conf_data = { 10 | "embedding": { 11 | "enable": True, 12 | "db_store_folder": tmp_path, 13 | } 14 | } 15 | config = load_config_from_dict(conf_data) 16 | yield config 17 | clear_config() 18 | 19 | 20 | def easy_embedding(content: str) -> List[float]: 21 | embed = [float(x) for x in content.encode()] 22 | # right pad with zeros 23 | _width = 64 24 | for _ in range(_width - len(embed)): 25 | embed.append(0.0) 26 | return embed[:_width] 27 | 28 | 29 | def test_json_db(mock_config): 30 | # Build DB 31 | txs = [ 32 | { 33 | "hash": "hash-1", 34 | "occurance": 1, 35 | "sentence": "sentence-1", 36 | "content": "content-1", 37 | "embedding": easy_embedding("content-1"), 38 | }, 39 | { 40 | "hash": "hash-2", 41 | "occurance": 1, 42 | "sentence": "sentence-2", 43 | "content": "content-2", 44 | "embedding": easy_embedding("content-2"), 45 | }, 46 | { 47 | "hash": "hash-3", 48 | "occurance": 1, 49 | "sentence": "sentence-3", 50 | "content": "another-3", 51 | "embedding": easy_embedding("another-3"), 52 | }, 53 | ] 54 | # Query when DB not exists 55 | candidates = json_vec_db.query_by_embedding( 56 | easy_embedding("content-1"), "sentence-1", 2, 57 | ) 58 | assert candidates is None 59 | # Build DB 60 | json_vec_db.build_db(txs) 61 | db_path = json_vec_db._get_db_name() 62 | assert db_path.exists() 63 | # Query DB 64 | candidates = json_vec_db.query_by_embedding( 65 | easy_embedding("content-1"), "sentence-1", 2, 66 | ) 67 | assert len(candidates) == 2 68 | assert candidates[0]["hash"] == "hash-1" 69 | assert candidates[1]["hash"] == "hash-2" 70 | # Cleanup 71 | db_path.unlink() 72 | -------------------------------------------------------------------------------- /locale/beanbot.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 19:08+0800\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 21 | #, python-brace-format 22 | msgid "Account {acc} not found" 23 | msgstr "" 24 | 25 | #: bots/controller.py:32 26 | msgid "Embedding is not enabled." 27 | msgstr "" 28 | 29 | #: bots/controller.py:35 30 | #, python-brace-format 31 | msgid "Token usage: {tokens}" 32 | msgstr "" 33 | 34 | #: bots/controller.py:51 35 | #, python-brace-format 36 | msgid "Expenditures on {start}" 37 | msgstr "" 38 | 39 | #: bots/controller.py:54 40 | #, python-brace-format 41 | msgid "Expenditures between {start} - {end}" 42 | msgstr "" 43 | 44 | #: bots/controller.py:55 bots/controller.py:69 45 | msgid "Account" 46 | msgstr "" 47 | 48 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 49 | msgid "Position" 50 | msgstr "" 51 | 52 | #: bots/controller.py:65 53 | #, python-brace-format 54 | msgid "Account changes on {start}" 55 | msgstr "" 56 | 57 | #: bots/controller.py:68 58 | #, python-brace-format 59 | msgid "Account changes between {start} - {end}" 60 | msgstr "" 61 | 62 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 63 | msgid "Submit" 64 | msgstr "" 65 | 66 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 67 | msgid "Cancel" 68 | msgstr "" 69 | 70 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 71 | msgid "Query account changes" 72 | msgstr "" 73 | 74 | #: bots/mattermost_bot.py:131 75 | msgid "Query expenses" 76 | msgstr "" 77 | 78 | #: bots/telegram_bot.py:137 79 | msgid "Submitted ✅" 80 | msgstr "" 81 | 82 | #: bots/telegram_bot.py:141 83 | msgid "Cancelled ❌" 84 | msgstr "" 85 | 86 | #: bots/web_bot.py:64 87 | msgid "Message should not be empty." 88 | msgstr "" 89 | 90 | #: bots/web_bot.py:69 91 | msgid "Message must start with a number." 92 | msgstr "" 93 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".ipynb_checkpoints", 10 | ".mypy_cache", 11 | ".nox", 12 | ".pants.d", 13 | ".pyenv", 14 | ".pytest_cache", 15 | ".pytype", 16 | ".ruff_cache", 17 | ".svn", 18 | ".tox", 19 | ".venv", 20 | ".vscode", 21 | "__pypackages__", 22 | "_build", 23 | "buck-out", 24 | "build", 25 | "dist", 26 | "node_modules", 27 | "site-packages", 28 | "venv", 29 | "test.py", 30 | ] 31 | 32 | # Same as Black. 33 | line-length = 120 34 | indent-width = 4 35 | 36 | # Assume Python 3.9 37 | target-version = "py39" 38 | 39 | [lint] 40 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 41 | select = ["E4", "E7", "E9", "F", "DJ", 42 | "YTT", "ASYNC", "ASYNC", "S", "BLE", "B", "A", "C4", "DTZ", "T10", "DJ", "EM", "EXE", "ISC", "ICN", "LOG", "G", "INP", "PIE", "T20", "PYI", "PT", "RSE", "RET", "SLF", "SLOT", "SIM", "TID", "TCH", "INT", "PTH", "TD", "FIX"] 43 | ignore = ["S608", "PTH123", "SIM108"] 44 | 45 | # Allow fix for all enabled rules (when `--fix`) is provided. 46 | fixable = ["ALL"] 47 | unfixable = [] 48 | 49 | # Allow unused variables when underscore-prefixed. 50 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 51 | 52 | [lint.extend-per-file-ignores] 53 | "**/*_test.py" = [ 54 | # at least this three should be fine in tests: 55 | "S101", # asserts allowed in tests... 56 | "ARG", # Unused function args -> fixtures nevertheless are functionally relevant... 57 | "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize() 58 | "SLF001", # Private member accessed 59 | "F811", # Re-use of pytest fixture will cause redefinition of unused variable 60 | "F401", # Import but unused on pytest fixture 61 | # The below are debateable 62 | "PLR2004", # Magic value used in comparison, ... 63 | "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes 64 | ] 65 | 66 | [format] 67 | # Like Black, use double quotes for strings. 68 | quote-style = "double" 69 | 70 | # Like Black, indent with spaces, rather than tabs. 71 | indent-style = "space" 72 | 73 | # Like Black, respect magic trailing commas. 74 | skip-magic-trailing-comma = false 75 | 76 | # Like Black, automatically detect the appropriate line ending. 77 | line-ending = "auto" 78 | -------------------------------------------------------------------------------- /frontend/backup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Beancount Bot 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 |
23 | 29 | 30 |
31 | 34 |
35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /config.yaml.example: -------------------------------------------------------------------------------- 1 | language: zh_CN # If set, it can override the default locale from environment variables 2 | 3 | beancount: 4 | filename: main.bean # The entrypoint for all transactions, and generated transaction will also append to this file 5 | currency: CNY 6 | account_distinguation_range: 3 # The range of accounts segments to distinguish itself 7 | # account_distinguation_range: [3,5] # Support list (zero-indexed, closed interval) and int 8 | 9 | bot: 10 | telegram: 11 | token: "{your_bot_token_here}" 12 | chat_id: 12345678 # Your chat id 13 | mattermost: # See https://mmpy-bot.readthedocs.io/en/latest/plugins.html 14 | server_url: "{mattermost_server_url}" 15 | server_port: 443 16 | bot_token: "{mattermost_bot_token}" 17 | bot_team: "{bot_team}" 18 | ssl_verify: false 19 | webhook_host_port: 8308 20 | webhook_host_url: "{webhook_host}" 21 | owner_user: "{chat_owner}" 22 | web: 23 | chat_db: "chat_db.sqlite" 24 | host: "0.0.0.0" 25 | port: 8000 26 | 27 | embedding: 28 | enable: true # Disable it if you care about privacy 29 | api_url: "https://api.siliconflow.cn/v1/embeddings" # OpenAI compatible API endpoint 30 | api_key: "{your_key_here}" 31 | model: "BAAI/bge-large-zh-v1.5" 32 | db_store_folder: "." # The folder to store vector db (tx_db.json or tx_db.sqlite) 33 | transaction_amount: 1000 # Only fetch the latest 1000 dinstinct transactions when building vector DB 34 | candidates: 3 # Select 3 entry and sort them with weight 35 | output_amount: 1 # Output at most 1 candidates during vector match 36 | 37 | rag: 38 | enable: false # Disable it if you care about privacy, disabled by default 39 | api_url: "https://api.deepseek.com/v1/chat/completions" # OpenAI compatible API endpoint 40 | api_key: "{your_key_here}" 41 | model: "deepseek-chat" 42 | 43 | # Logging config, you can specify any key to override the default config (e.g. level only) 44 | # See https://docs.python.org/3/library/logging.config.html#logging-config-dictschema 45 | logging: 46 | formatters: 47 | standard: 48 | format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 49 | handlers: 50 | console: 51 | class: logging.StreamHandler 52 | level: WARNING 53 | formatter: standard 54 | stream: ext://sys.stdout 55 | loggers: 56 | beanbot: 57 | level: WARNING 58 | handlers: [console] 59 | propagate: no 60 | -------------------------------------------------------------------------------- /locale/zh_CN/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # Chinese translations for PACKAGE package 2 | # PACKAGE 软件包的简体中文翻译. 3 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # David Dai , 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 12 | "PO-Revision-Date: 2024-08-22 20:40+0800\n" 13 | "Last-Translator: David Dai \n" 14 | "Language-Team: Chinese (simplified) \n" 15 | "Language: zh_CN\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 21 | #, python-brace-format 22 | msgid "Account {acc} not found" 23 | msgstr "账户 {acc} 未找到" 24 | 25 | #: bots/controller.py:32 26 | msgid "Embedding is not enabled." 27 | msgstr "嵌入未启用。" 28 | 29 | #: bots/controller.py:35 30 | #, python-brace-format 31 | msgid "Token usage: {tokens}" 32 | msgstr "令牌使用量:{tokens}" 33 | 34 | #: bots/controller.py:51 35 | #, python-brace-format 36 | msgid "Expenditures on {start}" 37 | msgstr "{start} 的支出" 38 | 39 | #: bots/controller.py:54 40 | #, python-brace-format 41 | msgid "Expenditures between {start} - {end}" 42 | msgstr "{start} 至 {end} 之间的支出" 43 | 44 | #: bots/controller.py:55 bots/controller.py:69 45 | msgid "Account" 46 | msgstr "账户" 47 | 48 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 49 | msgid "Position" 50 | msgstr "持仓" 51 | 52 | #: bots/controller.py:65 53 | #, python-brace-format 54 | msgid "Account changes on {start}" 55 | msgstr "{start} 的账户变动" 56 | 57 | #: bots/controller.py:68 58 | #, python-brace-format 59 | msgid "Account changes between {start} - {end}" 60 | msgstr "{start} 至 {end} 之间的账户变动" 61 | 62 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 63 | msgid "Submit" 64 | msgstr "提交" 65 | 66 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 67 | msgid "Cancel" 68 | msgstr "取消" 69 | 70 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 71 | msgid "Query account changes" 72 | msgstr "查询账户变更" 73 | 74 | #: bots/mattermost_bot.py:131 75 | msgid "Query expenses" 76 | msgstr "查询支出" 77 | 78 | #: bots/telegram_bot.py:137 79 | msgid "Submitted ✅" 80 | msgstr "已提交 ✅" 81 | 82 | #: bots/telegram_bot.py:141 83 | msgid "Cancelled ❌" 84 | msgstr "已取消 ❌" 85 | 86 | #: bots/web_bot.py:64 87 | msgid "Message should not be empty." 88 | msgstr "消息不能为空。" 89 | 90 | #: bots/web_bot.py:69 91 | msgid "Message must start with a number." 92 | msgstr "消息必须以数字开头。" 93 | 94 | #, python-brace-format 95 | #~ msgid "Transaction between {start} - {end}" 96 | #~ msgstr "{start} 至 {end} 之间的交易" 97 | -------------------------------------------------------------------------------- /locale/zh_TW/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # Chinese translations for PACKAGE package 2 | # PACKAGE 套件的正體中文翻譯. 3 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # David Dai , 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 12 | "PO-Revision-Date: 2024-08-22 20:40+0800\n" 13 | "Last-Translator: David Dai \n" 14 | "Language-Team: Chinese (traditional) \n" 15 | "Language: zh_TW\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 21 | #, python-brace-format 22 | msgid "Account {acc} not found" 23 | msgstr "帳戶 {acc} 未找到" 24 | 25 | #: bots/controller.py:32 26 | msgid "Embedding is not enabled." 27 | msgstr "嵌入未啟用。" 28 | 29 | #: bots/controller.py:35 30 | #, python-brace-format 31 | msgid "Token usage: {tokens}" 32 | msgstr "令牌使用量:{tokens}" 33 | 34 | #: bots/controller.py:51 35 | #, python-brace-format 36 | msgid "Expenditures on {start}" 37 | msgstr "在 {start} 的支出" 38 | 39 | #: bots/controller.py:54 40 | #, python-brace-format 41 | msgid "Expenditures between {start} - {end}" 42 | msgstr "在 {start} 到 {end} 之間的支出" 43 | 44 | #: bots/controller.py:55 bots/controller.py:69 45 | msgid "Account" 46 | msgstr "帳戶" 47 | 48 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 49 | msgid "Position" 50 | msgstr "持倉" 51 | 52 | #: bots/controller.py:65 53 | #, python-brace-format 54 | msgid "Account changes on {start}" 55 | msgstr "在 {start} 的帳戶變動" 56 | 57 | #: bots/controller.py:68 58 | #, python-brace-format 59 | msgid "Account changes between {start} - {end}" 60 | msgstr "在 {start} 至 {end} 之間的帳戶變動" 61 | 62 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 63 | msgid "Submit" 64 | msgstr "提交" 65 | 66 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 67 | msgid "Cancel" 68 | msgstr "取消" 69 | 70 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 71 | msgid "Query account changes" 72 | msgstr "查詢賬戶變更" 73 | 74 | #: bots/mattermost_bot.py:131 75 | msgid "Query expenses" 76 | msgstr "查詢支出" 77 | 78 | #: bots/telegram_bot.py:137 79 | msgid "Submitted ✅" 80 | msgstr "已提交 ✅" 81 | 82 | #: bots/telegram_bot.py:141 83 | msgid "Cancelled ❌" 84 | msgstr "已取消 ❌" 85 | 86 | #: bots/web_bot.py:64 87 | msgid "Message should not be empty." 88 | msgstr "訊息不應為空。" 89 | 90 | #: bots/web_bot.py:69 91 | msgid "Message must start with a number." 92 | msgstr "訊息必須以數字開頭。" 93 | 94 | #, python-brace-format 95 | #~ msgid "Transaction between {start} - {end}" 96 | #~ msgstr "在 {start} 至 {end} 之間的交易" 97 | -------------------------------------------------------------------------------- /locale/es_ES/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # Spanish translations for PACKAGE package 2 | # Traducciones al español para el paquete PACKAGE. 3 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # David Dai , 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 12 | "PO-Revision-Date: 2024-08-23 07:49+0800\n" 13 | "Last-Translator: David Dai \n" 14 | "Language-Team: Spanish \n" 15 | "Language: es\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 22 | #, python-brace-format 23 | msgid "Account {acc} not found" 24 | msgstr "Cuenta {acc} no encontrada" 25 | 26 | #: bots/controller.py:32 27 | msgid "Embedding is not enabled." 28 | msgstr "Incrustación no está habilitada." 29 | 30 | #: bots/controller.py:35 31 | #, python-brace-format 32 | msgid "Token usage: {tokens}" 33 | msgstr "Uso de tokens: {tokens}" 34 | 35 | #: bots/controller.py:51 36 | #, python-brace-format 37 | msgid "Expenditures on {start}" 38 | msgstr "Gastos en {start}" 39 | 40 | #: bots/controller.py:54 41 | #, python-brace-format 42 | msgid "Expenditures between {start} - {end}" 43 | msgstr "Gastos entre {start} - {end}" 44 | 45 | #: bots/controller.py:55 bots/controller.py:69 46 | msgid "Account" 47 | msgstr "Cuenta" 48 | 49 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 50 | msgid "Position" 51 | msgstr "Posición" 52 | 53 | #: bots/controller.py:65 54 | #, python-brace-format 55 | msgid "Account changes on {start}" 56 | msgstr "Cambios en la cuenta en {start}" 57 | 58 | #: bots/controller.py:68 59 | #, python-brace-format 60 | msgid "Account changes between {start} - {end}" 61 | msgstr "Cambios en la cuenta entre {start} - {end}" 62 | 63 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 64 | msgid "Submit" 65 | msgstr "Enviar" 66 | 67 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 68 | msgid "Cancel" 69 | msgstr "Cancelar" 70 | 71 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 72 | msgid "Query account changes" 73 | msgstr "Consultar cambios en la cuenta" 74 | 75 | #: bots/mattermost_bot.py:131 76 | msgid "Query expenses" 77 | msgstr "Consultar gastos" 78 | 79 | #: bots/telegram_bot.py:137 80 | msgid "Submitted ✅" 81 | msgstr "Enviado ✅" 82 | 83 | #: bots/telegram_bot.py:141 84 | msgid "Cancelled ❌" 85 | msgstr "Cancelado ❌" 86 | 87 | #: bots/web_bot.py:64 88 | msgid "Message should not be empty." 89 | msgstr "" 90 | 91 | #: bots/web_bot.py:69 92 | msgid "Message must start with a number." 93 | msgstr "" 94 | -------------------------------------------------------------------------------- /locale/de_DE/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # German translations for PACKAGE package 2 | # German translation for PACKAGE. 3 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # David Dai , 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 12 | "PO-Revision-Date: 2024-08-23 07:49+0800\n" 13 | "Last-Translator: David Dai \n" 14 | "Language-Team: German \n" 15 | "Language: de\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 22 | #, python-brace-format 23 | msgid "Account {acc} not found" 24 | msgstr "Konto {acc} nicht gefunden" 25 | 26 | #: bots/controller.py:32 27 | msgid "Embedding is not enabled." 28 | msgstr "Einbettung ist nicht aktiviert." 29 | 30 | #: bots/controller.py:35 31 | #, python-brace-format 32 | msgid "Token usage: {tokens}" 33 | msgstr "Token-Nutzung: {tokens}" 34 | 35 | #: bots/controller.py:51 36 | #, python-brace-format 37 | msgid "Expenditures on {start}" 38 | msgstr "Ausgaben am {start}" 39 | 40 | #: bots/controller.py:54 41 | #, python-brace-format 42 | msgid "Expenditures between {start} - {end}" 43 | msgstr "Ausgaben zwischen {start} - {end}" 44 | 45 | #: bots/controller.py:55 bots/controller.py:69 46 | msgid "Account" 47 | msgstr "Konto" 48 | 49 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 50 | msgid "Position" 51 | msgstr "Position" 52 | 53 | #: bots/controller.py:65 54 | #, python-brace-format 55 | msgid "Account changes on {start}" 56 | msgstr "Kontoänderungen am {start}" 57 | 58 | #: bots/controller.py:68 59 | #, python-brace-format 60 | msgid "Account changes between {start} - {end}" 61 | msgstr "Kontoänderungen zwischen {start} - {end}" 62 | 63 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 64 | msgid "Submit" 65 | msgstr "Einreichen" 66 | 67 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 68 | msgid "Cancel" 69 | msgstr "Abbrechen" 70 | 71 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 72 | msgid "Query account changes" 73 | msgstr "Kontoänderungen abfragen" 74 | 75 | #: bots/mattermost_bot.py:131 76 | msgid "Query expenses" 77 | msgstr "Ausgaben abfragen" 78 | 79 | #: bots/telegram_bot.py:137 80 | msgid "Submitted ✅" 81 | msgstr "Eingereicht ✅" 82 | 83 | #: bots/telegram_bot.py:141 84 | msgid "Cancelled ❌" 85 | msgstr "Abgebrochen ❌" 86 | 87 | #: bots/web_bot.py:64 88 | msgid "Message should not be empty." 89 | msgstr "" 90 | 91 | #: bots/web_bot.py:69 92 | msgid "Message must start with a number." 93 | msgstr "" 94 | -------------------------------------------------------------------------------- /locale/ko_KR/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # Korean translations for PACKAGE package 2 | # PACKAGE ��Ű���� ���� �ѱ��� ������. 3 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # David Dai , 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 12 | "PO-Revision-Date: 2024-08-22 21:08+0800\n" 13 | "Last-Translator: David Dai \n" 14 | "Language-Team: Korean \n" 15 | "Language: ko\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | 21 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 22 | #, python-brace-format 23 | msgid "Account {acc} not found" 24 | msgstr "계정 {acc}을(를) 찾을 수 없습니다" 25 | 26 | #: bots/controller.py:32 27 | msgid "Embedding is not enabled." 28 | msgstr "임베딩이 활성화되지 않았습니다." 29 | 30 | #: bots/controller.py:35 31 | #, python-brace-format 32 | msgid "Token usage: {tokens}" 33 | msgstr "토큰 사용량: {tokens}" 34 | 35 | #: bots/controller.py:51 36 | #, python-brace-format 37 | msgid "Expenditures on {start}" 38 | msgstr "{start}의 지출" 39 | 40 | #: bots/controller.py:54 41 | #, python-brace-format 42 | msgid "Expenditures between {start} - {end}" 43 | msgstr "{start}부터 {end}까지의 지출" 44 | 45 | #: bots/controller.py:55 bots/controller.py:69 46 | msgid "Account" 47 | msgstr "계정" 48 | 49 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 50 | msgid "Position" 51 | msgstr "포지션" 52 | 53 | #: bots/controller.py:65 54 | #, python-brace-format 55 | msgid "Account changes on {start}" 56 | msgstr "{start}에 대한 계정 변경" 57 | 58 | #: bots/controller.py:68 59 | #, python-brace-format 60 | msgid "Account changes between {start} - {end}" 61 | msgstr "{start}부터 {end}까지의 계정 변경" 62 | 63 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 64 | msgid "Submit" 65 | msgstr "제출" 66 | 67 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 68 | msgid "Cancel" 69 | msgstr "취소" 70 | 71 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 72 | msgid "Query account changes" 73 | msgstr "계정 변경 조회" 74 | 75 | #: bots/mattermost_bot.py:131 76 | msgid "Query expenses" 77 | msgstr "지출 조회" 78 | 79 | #: bots/telegram_bot.py:137 80 | msgid "Submitted ✅" 81 | msgstr "제출됨 ✅" 82 | 83 | #: bots/telegram_bot.py:141 84 | msgid "Cancelled ❌" 85 | msgstr "취소됨 ❌" 86 | 87 | #: bots/web_bot.py:64 88 | msgid "Message should not be empty." 89 | msgstr "메시지는 비어 있을 수 없습니다." 90 | 91 | #: bots/web_bot.py:69 92 | msgid "Message must start with a number." 93 | msgstr "메시지는 숫자로 시작해야 합니다." 94 | 95 | #, python-brace-format 96 | #~ msgid "Transaction between {start} - {end}" 97 | #~ msgstr "{start}부터 {end}까지의 거래" 98 | -------------------------------------------------------------------------------- /locale/ja_JP/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # Japanese translations for PACKAGE package 2 | # PACKAGE �ѥå��������Ф������. 3 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # David Dai , 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 12 | "PO-Revision-Date: 2024-08-22 21:07+0800\n" 13 | "Last-Translator: David Dai \n" 14 | "Language-Team: Japanese \n" 15 | "Language: ja\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | 21 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 22 | #, python-brace-format 23 | msgid "Account {acc} not found" 24 | msgstr "アカウント {acc} が見つかりません" 25 | 26 | #: bots/controller.py:32 27 | msgid "Embedding is not enabled." 28 | msgstr "埋め込みが有効になっていません。" 29 | 30 | #: bots/controller.py:35 31 | #, python-brace-format 32 | msgid "Token usage: {tokens}" 33 | msgstr "トークン使用量: {tokens}" 34 | 35 | #: bots/controller.py:51 36 | #, python-brace-format 37 | msgid "Expenditures on {start}" 38 | msgstr "{start} の支出" 39 | 40 | #: bots/controller.py:54 41 | #, python-brace-format 42 | msgid "Expenditures between {start} - {end}" 43 | msgstr "{start} から {end} までの支出" 44 | 45 | #: bots/controller.py:55 bots/controller.py:69 46 | msgid "Account" 47 | msgstr "アカウント" 48 | 49 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 50 | msgid "Position" 51 | msgstr "ポジション" 52 | 53 | #: bots/controller.py:65 54 | #, python-brace-format 55 | msgid "Account changes on {start}" 56 | msgstr "{start} でのアカウント変更" 57 | 58 | #: bots/controller.py:68 59 | #, python-brace-format 60 | msgid "Account changes between {start} - {end}" 61 | msgstr "{start} から {end} までのアカウント変更" 62 | 63 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 64 | msgid "Submit" 65 | msgstr "送信" 66 | 67 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 68 | msgid "Cancel" 69 | msgstr "キャンセル" 70 | 71 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 72 | msgid "Query account changes" 73 | msgstr "口座変更の照会" 74 | 75 | #: bots/mattermost_bot.py:131 76 | msgid "Query expenses" 77 | msgstr "経費の照会" 78 | 79 | #: bots/telegram_bot.py:137 80 | msgid "Submitted ✅" 81 | msgstr "送信済み ✅" 82 | 83 | #: bots/telegram_bot.py:141 84 | msgid "Cancelled ❌" 85 | msgstr "キャンセル済み ❌" 86 | 87 | #: bots/web_bot.py:64 88 | msgid "Message should not be empty." 89 | msgstr "メッセージは空であってはいけません。" 90 | 91 | #: bots/web_bot.py:69 92 | msgid "Message must start with a number." 93 | msgstr "メッセージは数字で始まる必要があります。" 94 | 95 | #, python-brace-format 96 | #~ msgid "Transaction between {start} - {end}" 97 | #~ msgstr "{start} から {end} までの取引" 98 | -------------------------------------------------------------------------------- /locale/en/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # English translations for PACKAGE package. 2 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # David Dai , 2024. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 11 | "PO-Revision-Date: 2024-08-22 20:40+0800\n" 12 | "Last-Translator: David Dai \n" 13 | "Language-Team: English\n" 14 | "Language: en\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 21 | #, python-brace-format 22 | msgid "Account {acc} not found" 23 | msgstr "Account {acc} not found" 24 | 25 | #: bots/controller.py:32 26 | msgid "Embedding is not enabled." 27 | msgstr "Embedding is not enabled." 28 | 29 | #: bots/controller.py:35 30 | #, python-brace-format 31 | msgid "Token usage: {tokens}" 32 | msgstr "Token usage: {tokens}" 33 | 34 | #: bots/controller.py:51 35 | #, python-brace-format 36 | msgid "Expenditures on {start}" 37 | msgstr "Expenditures on {start}" 38 | 39 | #: bots/controller.py:54 40 | #, python-brace-format 41 | msgid "Expenditures between {start} - {end}" 42 | msgstr "Expenditures between {start} - {end}" 43 | 44 | #: bots/controller.py:55 bots/controller.py:69 45 | msgid "Account" 46 | msgstr "Account" 47 | 48 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 49 | msgid "Position" 50 | msgstr "Position" 51 | 52 | #: bots/controller.py:65 53 | #, python-brace-format 54 | msgid "Account changes on {start}" 55 | msgstr "Account changes on {start}" 56 | 57 | #: bots/controller.py:68 58 | #, python-brace-format 59 | msgid "Account changes between {start} - {end}" 60 | msgstr "Account changes between {start} - {end}" 61 | 62 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 63 | msgid "Submit" 64 | msgstr "Submit" 65 | 66 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 67 | msgid "Cancel" 68 | msgstr "Cancel" 69 | 70 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 71 | msgid "Query account changes" 72 | msgstr "Query account changes" 73 | 74 | #: bots/mattermost_bot.py:131 75 | msgid "Query expenses" 76 | msgstr "Query expenses" 77 | 78 | #: bots/telegram_bot.py:137 79 | msgid "Submitted ✅" 80 | msgstr "Submitted ✅" 81 | 82 | #: bots/telegram_bot.py:141 83 | msgid "Cancelled ❌" 84 | msgstr "Cancelled ❌" 85 | 86 | #: bots/web_bot.py:64 87 | msgid "Message should not be empty." 88 | msgstr "Message should not be empty." 89 | 90 | #: bots/web_bot.py:69 91 | msgid "Message must start with a number." 92 | msgstr "Message must start with a number." 93 | 94 | #, python-brace-format 95 | #~ msgid "Transaction between {start} - {end}" 96 | #~ msgstr "Transaction between {start} - {end}" 97 | -------------------------------------------------------------------------------- /locale/fr_FR/LC_MESSAGES/beanbot.po: -------------------------------------------------------------------------------- 1 | # French translations for PACKAGE package 2 | # Traductions françaises du paquet PACKAGE. 3 | # Copyright (C) 2024 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # David Dai , 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-31 18:52+0800\n" 12 | "PO-Revision-Date: 2024-08-22 21:07+0800\n" 13 | "Last-Translator: David Dai \n" 14 | "Language-Team: French \n" 15 | "Language: fr\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: bean_utils/bean.py:207 bean_utils/bean.py:214 22 | #, python-brace-format 23 | msgid "Account {acc} not found" 24 | msgstr "Compte {acc} non trouvé" 25 | 26 | #: bots/controller.py:32 27 | msgid "Embedding is not enabled." 28 | msgstr "L'embedding n'est pas activé." 29 | 30 | #: bots/controller.py:35 31 | #, python-brace-format 32 | msgid "Token usage: {tokens}" 33 | msgstr "Utilisation des tokens : {tokens}" 34 | 35 | #: bots/controller.py:51 36 | #, python-brace-format 37 | msgid "Expenditures on {start}" 38 | msgstr "Dépenses le {start}" 39 | 40 | #: bots/controller.py:54 41 | #, python-brace-format 42 | msgid "Expenditures between {start} - {end}" 43 | msgstr "Dépenses entre {start} et {end}" 44 | 45 | #: bots/controller.py:55 bots/controller.py:69 46 | msgid "Account" 47 | msgstr "Compte" 48 | 49 | #: bots/controller.py:55 bots/controller.py:69 conf/i18n_test.py:9 50 | msgid "Position" 51 | msgstr "Position" 52 | 53 | #: bots/controller.py:65 54 | #, python-brace-format 55 | msgid "Account changes on {start}" 56 | msgstr "Changements de compte à partir de {start}" 57 | 58 | #: bots/controller.py:68 59 | #, python-brace-format 60 | msgid "Account changes between {start} - {end}" 61 | msgstr "Changements de compte entre {start} et {end}" 62 | 63 | #: bots/mattermost_bot.py:82 bots/telegram_bot.py:106 64 | msgid "Submit" 65 | msgstr "Soumettre" 66 | 67 | #: bots/mattermost_bot.py:83 bots/telegram_bot.py:107 68 | msgid "Cancel" 69 | msgstr "Annuler" 70 | 71 | #: bots/mattermost_bot.py:114 conf/i18n_test.py:18 72 | msgid "Query account changes" 73 | msgstr "Interroger les changements de compte" 74 | 75 | #: bots/mattermost_bot.py:131 76 | msgid "Query expenses" 77 | msgstr "Interroger les dépenses" 78 | 79 | #: bots/telegram_bot.py:137 80 | msgid "Submitted ✅" 81 | msgstr "Soumis ✅" 82 | 83 | #: bots/telegram_bot.py:141 84 | msgid "Cancelled ❌" 85 | msgstr "Annulé ❌" 86 | 87 | #: bots/web_bot.py:64 88 | msgid "Message should not be empty." 89 | msgstr "Le message ne doit pas être vide." 90 | 91 | #: bots/web_bot.py:69 92 | msgid "Message must start with a number." 93 | msgstr "Le message doit commencer par un chiffre." 94 | 95 | #, python-brace-format 96 | #~ msgid "Transaction between {start} - {end}" 97 | #~ msgstr "Transaction entre {start} et {end}" 98 | -------------------------------------------------------------------------------- /bean_utils/rag.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from vec_db import query_by_embedding 3 | from bean_utils import vec_query 4 | import conf 5 | 6 | 7 | _TIMEOUT = 30 8 | # flake8: noqa 9 | _PROMPT_TEMPLATE = """The user is using Beancount for bookkeeping. For simplicity, there is currently a set of accounting grammar that is converted by a program into complete transaction records. The format of the grammar is ` [] [] [# [#] ...]`, where the inflow and outflow accounts are subject to fuzzy matching. 10 | 11 | For example:`5 微信 餐饮 麦当劳 午饭 #tag1 #another` will be converted to the following record: 12 | 13 | 2024-08-16 * "麦当劳" "午饭" #tag1 #another 14 | Assets:Checking:微信支付:Deposit -5.00 CNY 15 | Expenses:Daily:餐饮 16 | 17 | However, user input is not accurate enough and may be missing some information, maybe it's payee or description, or one or all of accounts. 18 | I will provide you with several reference sets, hoping that you can combine the reference information with the user's input to piece together a complete accounting record. 19 | The user's input will be given by user. 20 | 21 | You can do it as following: 22 | 1. Try your best to find the correct place for every given word from the reference sets, but not the accounting grammar. 23 | 2. If any information is missing, you should take the information from the reference sets and try to fill the missing part. 24 | 3. Only output the complete accounting record, without any quotes or delemeters. 25 | 26 | Finally, there are some reference information. 27 | Today's date: {date} 28 | Reference account names are: `{accounts}` 29 | Reference records are separated by dash delimiter: 30 | {reference_records} 31 | """ 32 | 33 | 34 | def complete_rag(args, date, accounts): 35 | # Remove the numeric value at first 36 | stripped_input = " ".join(args[1:]) 37 | 38 | candidates = conf.config.embedding.candidates or 3 39 | rag_config = conf.config.rag 40 | 41 | match = query_by_embedding(vec_query.embedding([stripped_input])[0][0]["embedding"], stripped_input, candidates) 42 | reference_records = "\n------\n".join([x["content"] for x in match]) 43 | prompt = _PROMPT_TEMPLATE.format(date=date, reference_records=reference_records, accounts=accounts) 44 | payload = { 45 | "model": rag_config.model, 46 | "stream": False, 47 | "messages": [ 48 | { 49 | "role": "system", 50 | "content": prompt, 51 | }, 52 | { 53 | "role": "user", 54 | "content": " ".join(args), 55 | } 56 | ], 57 | } 58 | headers = { 59 | "Content-Type": "application/json", 60 | "Authorization": f"Bearer {rag_config.api_key}", 61 | } 62 | response = requests.post(rag_config.api_url, json=payload, headers=headers, timeout=_TIMEOUT) 63 | data = response.json() 64 | if "choices" in data: 65 | # ChatGPT-format response 66 | content = data["choices"][0]["message"]["content"] 67 | else: 68 | # Ollama-format response 69 | content = data["message"]["content"] 70 | return content.strip("`\n") 71 | -------------------------------------------------------------------------------- /frontend/dist/beancount.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/public/beancount.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # Beancount bot 2 | [![Maintainability](https://qlty.sh/gh/StdioA/projects/beancount-bot/maintainability.png)](https://qlty.sh/gh/StdioA/projects/beancount-bot) 3 | [![codecov](https://codecov.io/github/StdioA/beancount-bot/graph/badge.svg?token=PPEO1607AJ)](https://codecov.io/github/StdioA/beancount-bot) 4 | [![ghcr image size](https://ghcr-badge.egpl.dev/stdioa/beancount-bot/size?color=%2344cc11&tag=latest&label=image+size&trim=)](https://github.com/users/stdioa/packages/container/package/beancount-bot) 5 | 6 | [English Readme](README.md) 7 | 8 | 一个可以通过聊天软件快速手动记录简单交易的 beancount bot. 9 | 10 | * 前端支持 Telegram 和 Mattermost 11 | * 支持基本账目记录 12 | * 支持基本文法匹配:`{金额} {流出账户} {流入账户} {payee} {narration} [{#tag1} {#tag2}]` 13 | * 若当前数据中已存在相同 payee,则流入账户可省略 14 | * 匹配失败后,可以尝试从向量数据库中进行记录匹配,或通过 RAG 进行信息补全 15 | * 区间内支出统计:`/expense 2024-08` 16 | * 区间内账户变更统计:`/bill 2024-08` 17 | * 提交新记录后,会自动重载账目缓存 18 | 19 | ## 运行 20 | ### 通过 Docker 运行 21 | 从 [`config.yaml.example`](config.yaml.example) 复制一份 `config.yaml` 到账本所在目录,并按需更改其中的内容(具体配置含义可参考配置文件中的注释)。 22 | 23 | 然后下载 [docker/compose.yaml](docker/compose.yaml) 到账本所在目录。如果要运行 Mattermost bot,需要修改 `command` 的值,并配置 `ports` 以暴露端口接收 Webhook. 24 | 25 | 最后运行 `docker compose up -d` 即可。 26 | 27 | ### 通过命令行运行 28 | 安装基本依赖:`pip install -r requirements/requirements.txt` 29 | 30 | 若你的设备支持 [sqlite-vec](https://github.com/asg017/sqlite-vec),则可以额外安装向量数据库组件 `pip install sqlite-vec==0.1.1`,并使用 sqlite 作为数据库;若未安装 `sqlite-vec`,则 bot 会使用 json 来存储向量数据,并使用 numpy 进行向量计算。 31 | 32 | 如果要使用 Telegram 作为前端,则安装 `python-telegram-bot`: `pip install python-telegram-bot==21.4`; 33 | 如果要使用 Mattermost 作为前端,则安装 `mmpy-bot`: `pip install mmpy-bot==2.1.4`. 34 | 如果要使用 Web 作为前端,则安装 `mmpy-bot`: `pip install bottle==0.13.2`. 35 | 36 | 最后使用你中意的前端来运行 bot: 37 | - 基于 Telegram:`python main.py telegram -c config.yaml` 38 | - 基于 Mattermost:`python main.py mattermost -c config.yaml` 39 | - 基于 Web:`python main.py web -c config.yaml` 40 | 41 | ## 使用 42 | 若使用 Telegram 作为前端,可以预先在 [BotFather](https://telegram.me/BotFather) 处配置 bot 命令列表: 43 | 44 | ``` 45 | start - ping 46 | bill - 查询账户变动 47 | expense - 查询支出 48 | clone - 复制交易 49 | build - 重建向量数据库 50 | ``` 51 | 52 | 后续操作都以 Telegram 为前端举例,若使用 Mattermost 作为前端,则使用时的不同会单独注明。 53 | 54 | ### 基本记账 55 | 基本文法:`{金额} {流出账户} [{流入账户}] {payee} {narration} [{#tag1} {#tag2} ...]`,流出和流入账户支持部分匹配。 56 | 若当前数据中已存在相同 payee,则流入账户可省略(`{金额} {流出账户} {payee} {narration}`); 57 | 若以上匹配规则均失败,则会尝试根据现有信息从向量数据库中匹配一条最接近的数据,并更新它的金额和日期。依靠这种方法可以支持 `{金额} {payee}` 或 `{金额} {narration}` 等格式的记账。 58 | 59 | 输入后,bot 会补全交易信息并输出,用户可以选择提交或撤销这次更改。 60 | 61 | 基本记账示例 62 | 63 | ### 其他命令 64 | * `/build`: 重建向量数据库 65 | * `/expense {range} {level}`:统计某时间段内的账户支出情况,支持按账户层级组合 66 | * Mattermost 命令格式参照命令行格式,为 `expense [-l {level}] [{range}]` 67 | * level 默认为 2,range 默认为昨天 68 | * `/bill {range} {level}`:统计某时间段内的账户变更,支持按账户层级组合 69 | * Mattermost 为 `bill [-l {level}] [{range}]` 70 | * 参数默认设置同上 71 | * `/clone`:在已有的交易信息上回复该命令,则可以生成一条新交易,交易日期为当日 72 | * 由于 Mattermost 对消息引用的支持不够好完善,因此暂时不支持复制,后续可以考虑通过 reaction 等方式达成 73 | 74 | ## Roadmap 75 | - [x] 使用向量数据库匹配时,支持输出多条备选(以弥补准确率的缺陷) 76 | - [x] 再记一笔 77 | - [ ] 撤回交易 78 | - [x] Docker 支持 79 | - [x] 单元测试 80 | - [x] 基于 Web 的 Chat UI(只支持交易生成和提交) 81 | - [x] RAG(通过 LLM 进行更精确的元素替换,比如自动将“午饭”改成“晚饭”,或自动更改变更账户等) 82 | - [ ] 支持增量构建向量数据库(如果用 OpenAI 的 `text-embedding-3-large`,目前构建 1000 条交易组成的数据库大概只需要 ¥0.01,而且目前提供 embedding 的供应商大多不对 embedding 功能收费,所以优先级不高) 83 | 84 | 85 | ## Reference 86 | [开始使用 Beancount - Telegram bot](https://blog.stdioa.com/2020/09/using-beancount/#telegram-bot) 87 | -------------------------------------------------------------------------------- /vec_db/sqlite_vec_db.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from operator import itemgetter 3 | import sqlite3 4 | import sqlite_vec 5 | from typing import List 6 | import struct 7 | from vec_db.match import calculate_score 8 | import conf 9 | 10 | 11 | def serialize_f32(vector: List[float]) -> bytes: 12 | """serializes a list of floats into a compact "raw bytes" format""" 13 | return struct.pack("%sf" % len(vector), *vector) 14 | 15 | 16 | def _get_db_name(): 17 | DB_NAME = "tx_db.sqlite" 18 | db_dir = conf.config.embedding.get("db_store_folder", ".") 19 | return pathlib.Path(db_dir) / DB_NAME 20 | 21 | 22 | _db = None 23 | 24 | 25 | def get_db(): 26 | global _db 27 | if _db is not None: 28 | return _db 29 | 30 | _db = sqlite3.connect(_get_db_name()) 31 | _db.enable_load_extension(True) 32 | sqlite_vec.load(_db) 33 | _db.enable_load_extension(False) 34 | return _db 35 | 36 | 37 | def build_db(txs): 38 | db = get_db() 39 | 40 | embedding_dimention = 1 41 | if txs: 42 | embedding_dimention = len(txs[0]["embedding"]) 43 | 44 | # Drop table if exists 45 | db.execute("DROP TABLE IF EXISTS vec_items") 46 | db.execute("DROP TABLE IF EXISTS transactions") 47 | db.commit() 48 | db.execute("VACUUM") 49 | db.commit() 50 | # Create tables 51 | db.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(embedding float[{embedding_dimention}])") 52 | db.execute(""" 53 | CREATE TABLE IF NOT EXISTS transactions ( 54 | id integer primary key, 55 | hash varchar(64) unique, 56 | occurance integer, 57 | sentence text, 58 | content text)""") 59 | 60 | for id_, tx in enumerate(txs, 1): 61 | db.execute("INSERT INTO vec_items (rowid, embedding) VALUES (?, ?)", 62 | (id_, serialize_f32(tx["embedding"]))) 63 | db.execute("INSERT INTO transactions (id, hash, occurance, sentence, content) VALUES (?, ?, ?, ?, ?)", 64 | (id_, tx["hash"], tx["occurance"], tx["sentence"], tx["content"])) 65 | # flush db 66 | db.commit() 67 | 68 | 69 | def query_by_embedding(embedding, sentence, candidate_amount): 70 | db = get_db() 71 | 72 | try: 73 | # 1 - vec_distance_cosine(embedding, ?) is cosine similarity 74 | rows = db.execute( 75 | f""" 76 | SELECT 77 | rowid, 78 | 1-vec_distance_cosine(embedding, ?) AS similarity 79 | FROM vec_items 80 | ORDER BY similarity DESC LIMIT {candidate_amount} 81 | """, 82 | (serialize_f32(embedding),)).fetchall() 83 | except sqlite3.OperationalError as e: 84 | # Handle exception when vec_db is not built 85 | if "no such table" in e.args[0]: 86 | conf.logger.warning("Sqlite vector database is not built") 87 | return [] 88 | raise 89 | if not rows: 90 | return [] 91 | 92 | ids = [x[0] for x in rows] 93 | # Select from transactions table 94 | placeholder = ",".join(["?"] * len(ids)) 95 | row_names = ["id", "occurance", "sentence", "content"] 96 | rows_str = ", ".join(row_names) 97 | txs_rows = db.execute(f"SELECT {rows_str} FROM transactions WHERE id in ({placeholder})", ids).fetchall() 98 | txs_rows.sort(key=lambda x: ids.index(x[0])) 99 | # Merge result & distance 100 | candidates = [] 101 | for drow, tx in zip(rows, txs_rows): 102 | tx_row = dict(zip(row_names, tx)) 103 | tx_row["distance"] = drow[1] 104 | tx_row["score"] = calculate_score(tx_row, sentence) 105 | candidates.append(tx_row) 106 | 107 | candidates.sort(key=itemgetter("score"), reverse=True) 108 | return candidates 109 | -------------------------------------------------------------------------------- /bots/controller.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from dataclasses import dataclass 3 | from conf.i18n import gettext as _ 4 | from typing import List, Union, Any 5 | from beancount.core.inventory import Inventory 6 | import requests 7 | from bean_utils import vec_query 8 | from bean_utils.bean import bean_manager, NoTransactionError 9 | import conf 10 | 11 | 12 | @dataclass 13 | class BaseMessage: 14 | content: str 15 | 16 | 17 | @dataclass 18 | class ErrorMessage: 19 | content: str 20 | excption: Exception 21 | 22 | 23 | @dataclass 24 | class Table: 25 | title: str 26 | headers: List[str] 27 | rows: List[List[str]] 28 | 29 | 30 | def build_db() -> BaseMessage: 31 | if not conf.config.embedding.get("enable", True): 32 | return BaseMessage(content=_("Embedding is not enabled.")) 33 | entries = bean_manager.entries 34 | tokens = vec_query.build_tx_db(entries) 35 | return BaseMessage(content=_("Token usage: {tokens}").format(tokens=tokens)) 36 | 37 | 38 | def _translate_rows(rows: List[List[Any]]) -> List[List[str]]: 39 | parsed_rows = [] 40 | for row in rows: 41 | row_data = list(row) 42 | for i, obj in enumerate(row): 43 | if isinstance(obj, Inventory): 44 | row_data[i] = obj.to_string(parens=False) 45 | parsed_rows.append(row_data) 46 | return parsed_rows 47 | 48 | 49 | def fetch_expense(start: date, end: date, root_level: int = 2) -> Table: 50 | if (end - start).days == 1: 51 | title = _("Expenditures on {start}").format(start=start) 52 | else: 53 | # 查询这段时间的账户支出 54 | title = _("Expenditures between {start} - {end}").format(start=start, end=end) 55 | headers = [_("Account"), _("Position")] 56 | query = (f'SELECT ROOT(account, {root_level}) as acc, cost(sum(position)) AS cost ' 57 | f'WHERE date>={start} AND date<{end} AND ROOT(account, 1)="Expenses" GROUP BY acc;') 58 | 59 | __, rows = bean_manager.run_query(query) 60 | return Table(title=title, headers=headers, rows=_translate_rows(rows)) 61 | 62 | 63 | def fetch_bill(start: date, end: date, root_level: int = 2) -> Table: 64 | if (end - start).days == 1: 65 | title = _("Account changes on {start}").format(start=start) 66 | else: 67 | # 查询这段时间的账户变动 68 | title = _("Account changes between {start} - {end}").format(start=start, end=end) 69 | headers = [_("Account"), _("Position")] 70 | query = (f'SELECT ROOT(account, {root_level}) as acc, cost(sum(position)) AS cost ' 71 | f'WHERE date>={start} AND date<{end} GROUP BY acc ORDER BY acc;') 72 | 73 | # query = f'SELECT account, cost(sum(position)) AS cost 74 | # FROM OPEN ON {start} CLOSE ON {end} GROUP BY account ORDER BY account;' 75 | # 等同于 BALANCES FROM OPEN ON ... CLOSE ON ... 76 | # 查询结果中 Asset 均为关闭时间时刻的保有量 77 | __, rows = bean_manager.run_query(query) 78 | return Table(title=title, headers=headers, rows=_translate_rows(rows)) 79 | 80 | 81 | def clone_txs(message: str, amount=None) -> Union[BaseMessage, ErrorMessage]: 82 | try: 83 | cloned_txs = bean_manager.clone_trx(message, amount) 84 | except ValueError as e: 85 | if e == NoTransactionError: 86 | err_msg = e.args[0] 87 | else: 88 | err_msg = "{}: {}".format(e.__class__.__name__, str(e)) 89 | return ErrorMessage(err_msg, e) 90 | return BaseMessage(content=cloned_txs) 91 | 92 | 93 | def render_txs(message_str: str) -> Union[List[BaseMessage], ErrorMessage]: 94 | try: 95 | trxs = bean_manager.generate_trx(message_str) 96 | except (ValueError, requests.exceptions.RequestException) as e: 97 | rendered = "{}: {}".format(e.__class__.__name__, str(e)) 98 | return ErrorMessage(rendered, e) 99 | return [BaseMessage(tx) for tx in trxs] 100 | -------------------------------------------------------------------------------- /frontend/src/api.ts: -------------------------------------------------------------------------------- 1 | // API服务模块 2 | import type { Message, ErrorMessage } from './types.ts'; 3 | 4 | // API端点常量 5 | const API_ENDPOINTS = { 6 | MESSAGES: '/api/messages', 7 | CHAT: '/api/chat', 8 | SUBMIT: '/api/submit', 9 | CLONE: '/api/clone', 10 | FAVORITE: '/api/favorite', 11 | DELETE: '/api/delete', 12 | }; 13 | 14 | // 错误处理函数 15 | const handleApiError = async (response: Response): Promise => { 16 | try { 17 | return await response.json(); 18 | } catch (error) { 19 | return { error: `API错误: ${response.status} ${response.statusText} ${error}` }; 20 | } 21 | }; 22 | 23 | // 获取消息列表 24 | export async function fetchMessages(): Promise<{ messages: Message[], favorites: Message[] }> { 25 | const response = await fetch(API_ENDPOINTS.MESSAGES); 26 | if (!response.ok) { 27 | const errorData = await handleApiError(response); 28 | throw new Error(errorData.error || `获取消息失败: ${response.status} ${response.statusText}`); 29 | } 30 | return await response.json(); 31 | } 32 | 33 | // 发送新消息 34 | export async function sendChatMessage(message: string): Promise { 35 | const response = await fetch(API_ENDPOINTS.CHAT, { 36 | method: 'POST', 37 | headers: { 'Content-Type': 'application/json' }, 38 | body: JSON.stringify({ message }) 39 | }); 40 | 41 | if (!response.ok) { 42 | const errorData = await handleApiError(response); 43 | throw new Error(errorData.error || `发送消息失败: ${response.status} ${response.statusText}`); 44 | } 45 | 46 | return await response.json(); 47 | } 48 | 49 | // 提交交易 50 | export async function submitTransaction(id: number): Promise { 51 | await handleTransactionRequest(API_ENDPOINTS.SUBMIT, id); 52 | } 53 | 54 | // 提交带修改金额的交易 55 | export async function submitTransactionWithAmount(id: number, amount: string): Promise { 56 | const response = await fetch(API_ENDPOINTS.CLONE, { 57 | method: 'POST', 58 | headers: { 'Content-Type': 'application/json' }, 59 | body: JSON.stringify({ id, amount }) 60 | }); 61 | 62 | if (!response.ok) { 63 | const errorData = await handleApiError(response); 64 | throw new Error(errorData.error || `提交修改金额交易失败: ${response.status} ${response.statusText}`); 65 | } 66 | } 67 | 68 | // 克隆交易 69 | export async function cloneTransaction(id: number): Promise { 70 | await handleTransactionRequest(API_ENDPOINTS.CLONE, id); 71 | } 72 | 73 | // 切换收藏状态 74 | export async function toggleFavoriteStatus(id: number, favorite: boolean): Promise { 75 | const response = await fetch(API_ENDPOINTS.FAVORITE, { 76 | method: 'POST', 77 | headers: { 'Content-Type': 'application/json' }, 78 | body: JSON.stringify({ id, favorite }) 79 | }); 80 | 81 | if (!response.ok) { 82 | const errorData = await handleApiError(response); 83 | throw new Error(errorData.error || `切换收藏状态失败: ${response.status} ${response.statusText}`); 84 | } 85 | } 86 | 87 | // 删除消息 88 | export async function deleteMessage(id: number): Promise { 89 | const response = await fetch(API_ENDPOINTS.DELETE, { 90 | method: 'POST', 91 | headers: { 'Content-Type': 'application/json' }, 92 | body: JSON.stringify({ id }) 93 | }); 94 | 95 | if (!response.ok) { 96 | const errorData = await handleApiError(response); 97 | throw new Error(errorData.error || `删除消息失败: ${response.status} ${response.statusText}`); 98 | } 99 | } 100 | 101 | // 处理交易请求的通用函数 102 | async function handleTransactionRequest(endpoint: string, id: number): Promise { 103 | const response = await fetch(endpoint, { 104 | method: 'POST', 105 | headers: { 'Content-Type': 'application/json' }, 106 | body: JSON.stringify({ id }) 107 | }); 108 | 109 | if (!response.ok) { 110 | const errorData = await handleApiError(response); 111 | throw new Error(errorData.error || `交易操作失败: ${response.status} ${response.statusText}`); 112 | } 113 | } -------------------------------------------------------------------------------- /frontend/src/locales/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "translation": { 4 | "send": "Send", 5 | "submit": "Submit", 6 | "clone": "Clone", 7 | "close": "Close", 8 | "history": "History", 9 | "favourites": "Favourites", 10 | "trx_hint": "Input your transaction", 11 | "transaction_preview": "Transaction Preview", 12 | "modify_amount": "Modify Amount", 13 | "submit_modified": "Submit Modified" 14 | } 15 | }, 16 | "zh_CN": { 17 | "translation": { 18 | "send": "发送", 19 | "submit": "提交", 20 | "clone": "复制", 21 | "close": "关闭", 22 | "history": "历史", 23 | "favourites": "收藏", 24 | "trx_hint": "输入您的交易", 25 | "transaction_preview": "交易预览", 26 | "modify_amount": "修改金额", 27 | "submit_modified": "提交修改" 28 | } 29 | }, 30 | "fr_FR": { 31 | "translation": { 32 | "send": "Envoyer", 33 | "submit": "Soumettre", 34 | "clone": "Cloner", 35 | "close": "Fermer", 36 | "history": "Historique", 37 | "favourites": "Favoris", 38 | "trx_hint": "Saisissez votre transaction", 39 | "transaction_preview": "Aperçu de la transaction", 40 | "modify_amount": "Modifier le montant", 41 | "submit_modified": "Soumettre modifié" 42 | } 43 | }, 44 | "ko_KR": { 45 | "translation": { 46 | "send": "보내기", 47 | "submit": "제출", 48 | "clone": "복제", 49 | "close": "닫기", 50 | "history": "이전", 51 | "favourites": "캐릭터", 52 | "trx_hint": "거래를 입력하세요", 53 | "transaction_preview": "거래 미리보기", 54 | "modify_amount": "금액 수정", 55 | "submit_modified": "수정 제출" 56 | } 57 | }, 58 | "de_DE": { 59 | "translation": { 60 | "send": "Senden", 61 | "submit": "Absenden", 62 | "clone": "Klonen", 63 | "close": "Schließen", 64 | "history": "Verlauf", 65 | "favourites": "Favoriten", 66 | "trx_hint": "Geben Sie Ihre Transaktion ein", 67 | "transaction_preview": "Transaktionsvorschau", 68 | "modify_amount": "Betrag ändern", 69 | "submit_modified": "Geändert absenden" 70 | } 71 | }, 72 | "es_ES": { 73 | "translation": { 74 | "send": "Enviar", 75 | "submit": "Enviar", 76 | "clone": "Clonar", 77 | "close": "Cerrar", 78 | "history": "Historial", 79 | "favourites": "Favoritos", 80 | "trx_hint": "Ingrese su transacción", 81 | "transaction_preview": "Vista previa de la transacción", 82 | "modify_amount": "Modificar cantidad", 83 | "submit_modified": "Enviar modificado" 84 | } 85 | }, 86 | "ja_JP": { 87 | "translation": { 88 | "send": "送信", 89 | "submit": "送信", 90 | "clone": "複製", 91 | "close": "閉じる", 92 | "history": "履歴", 93 | "favourites": "お気に入り", 94 | "trx_hint": "トランザクションを入力してください", 95 | "transaction_preview": "トランザクションプレビュー", 96 | "modify_amount": "金額を変更", 97 | "submit_modified": "変更を送信" 98 | } 99 | }, 100 | "ru_RU": { 101 | "translation": { 102 | "send": "Отправить", 103 | "submit": "Отправить", 104 | "clone": "Клонировать", 105 | "close": "Закрыть", 106 | "history": "История", 107 | "favourites": "Избранное", 108 | "trx_hint": "Введите вашу транзакцию", 109 | "transaction_preview": "Предварительный просмотр транзакции", 110 | "modify_amount": "Изменить сумму", 111 | "submit_modified": "Отправить изменения" 112 | } 113 | }, 114 | "zh_TW": { 115 | "translation": { 116 | "send": "發送", 117 | "submit": "提交", 118 | "clone": "複製", 119 | "close": "關閉", 120 | "history": "歷史", 121 | "favourites": "最愛", 122 | "trx_hint": "輸入您的交易", 123 | "transaction_preview": "交易預覽", 124 | "modify_amount": "修改金額", 125 | "submit_modified": "提交修改" 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /bots/controller_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, date 3 | from bean_utils import vec_query 4 | from conf.conf_test import load_config_from_dict, clear_config 5 | from bean_utils.bean import init_bean_manager 6 | from bots import controller 7 | from bean_utils.bean_test import assert_txs_equal, mock_embedding 8 | from conf.config_data import Config 9 | 10 | 11 | today = str(datetime.now().astimezone().date()) 12 | 13 | 14 | @pytest.fixture 15 | def mock_env(tmp_path, monkeypatch): 16 | conf_data = { 17 | "embedding": { 18 | "enable": False, 19 | "db_store_folder": tmp_path, 20 | }, 21 | "beancount": { 22 | "filename": "testdata/example.bean", 23 | "currency": "USD", 24 | "account_distinguation_range": [2, 3], 25 | } 26 | } 27 | config = load_config_from_dict(conf_data) 28 | manager = init_bean_manager() 29 | monkeypatch.setattr(controller, "bean_manager", manager) 30 | yield config 31 | clear_config() 32 | 33 | 34 | def test_fetch_expense(mock_env): 35 | # Start and end is the same 36 | start, end = date(2023, 6, 29), date(2023, 6, 30) 37 | resp_table = controller.fetch_expense(start, end) 38 | assert resp_table.title == "Expenditures on 2023-06-29" 39 | assert resp_table.headers == ["Account", "Position"] 40 | assert resp_table.rows == [ 41 | ["Expenses:Food", "31.59 USD"], 42 | ] 43 | 44 | # Start and end is different 45 | # Test level 46 | start, end = date(2023, 6, 1), date(2023, 7, 1) 47 | resp_table = controller.fetch_expense(start, end, root_level=1) 48 | assert resp_table.title == "Expenditures between 2023-06-01 - 2023-07-01" 49 | assert resp_table.headers == ["Account", "Position"] 50 | assert resp_table.rows == [ 51 | ["Expenses", "7207.08 USD, 2400.00 IRAUSD"], 52 | ] 53 | 54 | 55 | def test_fetch_bill(mock_env): 56 | # Start and end is the same 57 | start, end = date(2023, 6, 29), date(2023, 6, 30) 58 | resp_table = controller.fetch_bill(start, end) 59 | assert resp_table.title == "Account changes on 2023-06-29" 60 | assert resp_table.headers == ["Account", "Position"] 61 | assert resp_table.rows == [ 62 | ["Expenses:Food", "31.59 USD"], 63 | ["Liabilities:US", "-31.59 USD"], 64 | ] 65 | 66 | # Start and end is different 67 | # Test level 68 | start, end = date(2023, 6, 1), date(2023, 7, 1) 69 | resp_table = controller.fetch_bill(start, end, root_level=1) 70 | assert resp_table.title == "Account changes between 2023-06-01 - 2023-07-01" 71 | assert resp_table.headers == ["Account", "Position"] 72 | assert resp_table.rows == [ 73 | ['Assets', '3210.66768 USD, 10 VACHR, -2400.00 IRAUSD'], 74 | ['Expenses', '7207.08 USD, 2400.00 IRAUSD'], 75 | ['Income', '-10532.95 USD, -10 VACHR'], 76 | ['Liabilities', '115.20 USD'] 77 | ] 78 | 79 | 80 | def test_clone_txs(mock_env): 81 | # Normal generation 82 | param = """ 83 | 2023-05-23 * "Kin Soy" "Eating" #tag1 #tag2 84 | Assets:US:BofA:Checking -23.40 USD 85 | Expenses:Food:Restaurant 86 | """ 87 | exp_trx = f""" 88 | {today} * "Kin Soy" "Eating" #tag1 #tag2 89 | Assets:US:BofA:Checking -23.40 USD 90 | Expenses:Food:Restaurant 91 | """ 92 | response = controller.clone_txs(param) 93 | assert isinstance(response, controller.BaseMessage) 94 | assert_txs_equal(response.content, exp_trx) 95 | 96 | # Generate with error 97 | response = controller.clone_txs('') 98 | assert isinstance(response, controller.ErrorMessage) 99 | assert response.content == "No transaction found" 100 | 101 | 102 | def test_render_txs(mock_env): 103 | # Normal generation 104 | responses = controller.render_txs('23.4 BofA:Checking "Kin Soy" Eating #tag1 #tag2') 105 | assert len(responses) == 1 106 | exp_trx = f""" 107 | {today} * "Kin Soy" "Eating" #tag1 #tag2 108 | Assets:US:BofA:Checking -23.40 USD 109 | Expenses:Food:Restaurant 110 | """ 111 | assert isinstance(responses[0], controller.BaseMessage) 112 | assert_txs_equal(responses[0].content, exp_trx) 113 | 114 | # Generate with error 115 | response = controller.render_txs('10.00 ICBC:Checking NotFound McDonalds "Big Mac"') 116 | assert isinstance(response, controller.ErrorMessage) 117 | assert response.content == 'ValueError: Account ICBC:Checking not found' 118 | 119 | 120 | def test_build_db(monkeypatch, mock_env): 121 | # Build db without embedding enabled 122 | response = controller.build_db() 123 | assert isinstance(response, controller.BaseMessage) 124 | assert response.content == "Embedding is not enabled." 125 | 126 | # Build db with embedding enabled 127 | monkeypatch.setattr(mock_env, "embedding", Config.from_dict({ 128 | "enable": True, 129 | "transaction_amount": 100, 130 | "candidates": 3, 131 | "output_amount": 2, 132 | })) 133 | monkeypatch.setattr(vec_query, "embedding", mock_embedding) 134 | response = controller.build_db() 135 | assert isinstance(response, controller.BaseMessage) 136 | assert response.content == f"Token usage: {mock_env.embedding.transaction_amount}" 137 | -------------------------------------------------------------------------------- /frontend/dist/assets/workbox-window.prod.es5-B9K5rw8f.js: -------------------------------------------------------------------------------- 1 | try{self["workbox:window:7.2.0"]&&_()}catch{}function E(n,r){return new Promise(function(t){var i=new MessageChannel;i.port1.onmessage=function(c){t(c.data)},n.postMessage(r,[i.port2])})}function W(n){var r=function(t,i){if(typeof t!="object"||!t)return t;var c=t[Symbol.toPrimitive];if(c!==void 0){var h=c.call(t,i);if(typeof h!="object")return h;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(n,"string");return typeof r=="symbol"?r:r+""}function k(n,r){for(var t=0;tn.length)&&(r=n.length);for(var t=0,i=new Array(r);t=n.length?{done:!0}:{done:!1,value:n[i++]}}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. 2 | In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}try{self["workbox:core:7.2.0"]&&_()}catch{}var w=function(){var n=this;this.promise=new Promise(function(r,t){n.resolve=r,n.reject=t})};function b(n,r){var t=location.href;return new URL(n,t).href===new URL(r,t).href}var g=function(n,r){this.type=n,Object.assign(this,r)};function d(n,r,t){return t?r?r(n):n:(n&&n.then||(n=Promise.resolve(n)),r?n.then(r):n)}function O(){}var x={type:"SKIP_WAITING"};function S(n,r){return n&&n.then?n.then(O):Promise.resolve()}var U=function(n){function r(v,u){var e,o;return u===void 0&&(u={}),(e=n.call(this)||this).nn={},e.tn=0,e.rn=new w,e.en=new w,e.on=new w,e.un=0,e.an=new Set,e.cn=function(){var s=e.fn,a=s.installing;e.tn>0||!b(a.scriptURL,e.sn.toString())||performance.now()>e.un+6e4?(e.vn=a,s.removeEventListener("updatefound",e.cn)):(e.hn=a,e.an.add(a),e.rn.resolve(a)),++e.tn,a.addEventListener("statechange",e.ln)},e.ln=function(s){var a=e.fn,f=s.target,p=f.state,m=f===e.vn,y={sw:f,isExternal:m,originalEvent:s};!m&&e.mn&&(y.isUpdate=!0),e.dispatchEvent(new g(p,y)),p==="installed"?e.wn=self.setTimeout(function(){p==="installed"&&a.waiting===f&&e.dispatchEvent(new g("waiting",y))},200):p==="activating"&&(clearTimeout(e.wn),m||e.en.resolve(f))},e.yn=function(s){var a=e.hn,f=a!==navigator.serviceWorker.controller;e.dispatchEvent(new g("controlling",{isExternal:f,originalEvent:s,sw:a,isUpdate:e.mn})),f||e.on.resolve(a)},e.gn=(o=function(s){var a=s.data,f=s.ports,p=s.source;return d(e.getSW(),function(){e.an.has(p)&&e.dispatchEvent(new g("message",{data:a,originalEvent:s,ports:f,sw:p}))})},function(){for(var s=[],a=0;a> $GITHUB_ENV 31 | 32 | - name: Docker meta 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: | 37 | ${{ env.GHCR_REPO }} 38 | 39 | - name: Login to GHCR 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.repository_owner }} 44 | password: ${{ secrets.GHCR_PAT }} 45 | 46 | - name: Set up QEMU 47 | uses: docker/setup-qemu-action@v3 48 | 49 | - name: Set up Docker Buildx 50 | uses: docker/setup-buildx-action@v3 51 | 52 | - name: Build and push by digest 53 | id: build 54 | uses: docker/build-push-action@v6 55 | with: 56 | platforms: ${{ matrix.platform }} 57 | file: docker/Dockerfile 58 | labels: ${{ steps.meta.outputs.labels }} 59 | outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true 60 | 61 | - name: Export digest 62 | run: | 63 | mkdir -p ${{ runner.temp }}/digests 64 | digest="${{ steps.build.outputs.digest }}" 65 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 66 | 67 | - name: Upload digest 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: digests-${{ env.PLATFORM_PAIR }} 71 | path: ${{ runner.temp }}/digests/* 72 | if-no-files-found: error 73 | retention-days: 1 74 | 75 | build_armv7: 76 | runs-on: ubuntu-latest 77 | strategy: 78 | fail-fast: false 79 | matrix: 80 | platform: 81 | - linux/arm/v7 82 | steps: 83 | - name: Prepare 84 | run: | 85 | platform=${{ matrix.platform }} 86 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 87 | 88 | - name: Docker meta 89 | id: meta 90 | uses: docker/metadata-action@v5 91 | with: 92 | images: | 93 | ${{ env.GHCR_REPO }} 94 | 95 | - name: Login to GHCR 96 | uses: docker/login-action@v3 97 | with: 98 | registry: ghcr.io 99 | username: ${{ github.repository_owner }} 100 | password: ${{ secrets.GHCR_PAT }} 101 | 102 | - name: Set up QEMU 103 | uses: docker/setup-qemu-action@v3 104 | 105 | - name: Set up Docker Buildx 106 | uses: docker/setup-buildx-action@v3 107 | 108 | - name: Build and push by digest 109 | id: build 110 | uses: docker/build-push-action@v6 111 | with: 112 | platforms: ${{ matrix.platform }} 113 | file: docker/Dockerfile-armv7 114 | labels: ${{ steps.meta.outputs.labels }} 115 | outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true 116 | 117 | - name: Export digest 118 | run: | 119 | mkdir -p ${{ runner.temp }}/digests 120 | digest="${{ steps.build.outputs.digest }}" 121 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 122 | 123 | - name: Upload digest 124 | uses: actions/upload-artifact@v4 125 | with: 126 | name: digests-${{ env.PLATFORM_PAIR }} 127 | path: ${{ runner.temp }}/digests/* 128 | if-no-files-found: error 129 | retention-days: 1 130 | 131 | merge: 132 | runs-on: ubuntu-latest 133 | needs: 134 | - build 135 | - build_armv7 136 | steps: 137 | - name: Download digests 138 | uses: actions/download-artifact@v4 139 | with: 140 | path: ${{ runner.temp }}/digests 141 | pattern: digests-* 142 | merge-multiple: true 143 | 144 | - name: Login to GHCR 145 | uses: docker/login-action@v3 146 | with: 147 | registry: ghcr.io 148 | username: ${{ github.repository_owner }} 149 | password: ${{ secrets.GHCR_PAT }} 150 | 151 | - name: Set up Docker Buildx 152 | uses: docker/setup-buildx-action@v3 153 | 154 | - name: Docker meta 155 | id: meta 156 | uses: docker/metadata-action@v5 157 | with: 158 | images: | 159 | ${{ env.GHCR_REPO }} 160 | tags: | 161 | type=ref,event=branch 162 | type=raw,value=latest,enable={{is_default_branch}} 163 | 164 | - name: Create manifest list and push 165 | working-directory: ${{ runner.temp }}/digests 166 | run: | 167 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 168 | $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) 169 | 170 | - name: Inspect image 171 | run: | 172 | docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }} 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beancount Bot 2 | [![Maintainability](https://qlty.sh/gh/StdioA/projects/beancount-bot/maintainability.png)](https://qlty.sh/gh/StdioA/projects/beancount-bot) 3 | [![codecov](https://codecov.io/github/StdioA/beancount-bot/graph/badge.svg?token=PPEO1607AJ)](https://codecov.io/github/StdioA/beancount-bot) 4 | [![ghcr image size](https://ghcr-badge.egpl.dev/stdioa/beancount-bot/size?color=%2344cc11&tag=latest&label=image+size&trim=)](https://github.com/users/stdioa/packages/container/package/beancount-bot) 5 | 6 | [中文文档](README_zh.md) 7 | 8 | A Beancount bot that allows for quick manual recording of simple transactions via IM software. 9 | 10 | * Supporting Telegram and Mattermost as frontend 11 | * Supports basic account recording: 12 | * Supports basic grammar matching: `{amount} {from_account} {to_account} "{payee}" "{narration}" [{#tag1} {#tag2}]` 13 | * If the same payee already exists in the current data, the to_account can be omitted. 14 | * After matching failure, it can attempt record matching from a vector database or complete information through RAG. 15 | * Interval expenditure statistics: `/expense 2024-08` 16 | * Interval account change statistics: `/bill 2024-08` 17 | * After submitting a new record, it will automatically reload the ledger entry cache. 18 | 19 | ## Running 20 | ### Running with Docker 21 | Copy `config.yaml` from [`config.yaml.example`](config.yaml.example) to your ledger directory and modify its contents as needed (refer to comments in the configuration file for specific meanings). 22 | 23 | Then download [docker/compose.yaml](docker/compose.yaml) to the ledger directory. If you want to run a Mattermost bot, modify the `command` value and configure `ports` to expose ports for receiving Webhooks. 24 | 25 | Finally, run `docker compose up -d`. 26 | 27 | ### Running via Command Line 28 | Install basic dependencies firstly: `pip install -r requirements/requirements.txt` 29 | 30 | If your device supports [sqlite-vec](https://github.com/asg017/sqlite-vec), you can additionally install the vector database component `pip install sqlite-vec==0.1.1` and use sqlite as the database; if `sqlite-vec` is not installed, the bot will use json to store vector data and numpy for vector calculations. 31 | 32 | To use Telegram as a frontend, install `python-telegram-bot`: `pip install python-telegram-bot==21.4`; 33 | To use Mattermost as a frontend, install `mmpy-bot`: `pip install mmpy-bot==2.1.4`. 34 | To use Web as a frontend, install `mmpy-bot`: `pip install bottle==0.13.2`. 35 | 36 | Finally, run the bot based on your preference frontend: 37 | - Telegram-based: `python main.py telegram -c config.yaml` 38 | - Mattermost-based: `python main.py mattermost -c config.yaml`. 39 | - Web-based: `python main.py web -c config.yaml`. 40 | 41 | ## Usage 42 | If Telegram is used as the frontend, you can configure the bot command list in advance at [BotFather](https://telegram.me/BotFather): 43 | 44 | ``` 45 | start - ping 46 | bill - query account changes 47 | expense - query expenses 48 | clone - duplicate transaction 49 | build - rebuild vector database 50 | ``` 51 | 52 | Subsequent operations will be exemplified using Telegram as the frontend. If Mattermost is used as the frontend, any differences in usage will be noted separately. 53 | 54 | ### Basic Accounting 55 | Basic Syntax: `{Amount} {Outgoing Account} [{Incoming Account}] "{Payee}" "{Narration}" [{#tag1} {#tag2} ...]`, where outgoing and incoming accounts support partial matching. 56 | If the same payee already exists in the current data, the incoming account can be omitted (`{Amount} {Outgoing Account} "{Payee}" "{Narration}"`); 57 | If all matching rules fail, it will attempt to match the closest entry from an existing vector database based on available information, updating its amount and date. This method supports accounting formats like `{Amount} {Payee}` or `{Amount} {Narration}` among others. 58 | 59 | After input, the bot will complete the transaction details and output them for user confirmation or cancellation of changes. 60 | 61 | basic example of accounting 62 | 63 | ### Other Commands 64 | * `/build`: Rebuild the vector database. 65 | * `/expense {range} {level}`: Summarize account expenses within a specified time period, supports combination by account level. 66 | * Mattermost command format follows CLI style: `expense [-l {level}] [{range}]` 67 | * Default level is 2, default range is yesterday. 68 | * `/bill {range} {level}`: Summarize account changes within a specified time period, supports combination by account level. 69 | * Mattermost command: `bill [-l {level}] [{range}]` 70 | * Default parameter settings are the same as above. 71 | * `/clone`: Reply to an existing transaction with this command to generate a new transaction with today's date. 72 | * Due to Mattermost's limited support for message referencing, cloning is temporarily unsupported; future updates may consider using reactions or other methods to achieve this functionality. 73 | 74 | ## Roadmap 75 | - [x] Support outputting multiple alternatives when matching with vector database (to compensate for accuracy deficiencies) 76 | - [x] Clone transaction 77 | - [ ] Withdraw transaction 78 | - [x] Docker support 79 | - [x] Unit tests 80 | - [x] Web-based Chat UI (Only transaction generation & submit is supported) 81 | - [x] RAG (More precise element replacement through LLM, such as automatically changing "lunch" to "dinner", or automatically updating account changes, etc.) 82 | - [ ] Support incremental construction of vector databases (If using OpenAI's `text-embedding-3-large`, currently building a database consisting of 1000 transactions costs approximately $0.003, and most providers of embedding do not charge for the embedding function, so the priority is not high) 83 | 84 | ## Reference 85 | [开始使用 Beancount - Telegram bot](https://blog.stdioa.com/2020/09/using-beancount/#telegram-bot) 86 | -------------------------------------------------------------------------------- /bean_utils/vec_query.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from beancount.core.data import Transaction 3 | from beancount.core.compare import hash_entry 4 | import conf 5 | from vec_db import build_db, query_by_embedding 6 | 7 | 8 | _TIMEOUT = 30 9 | 10 | 11 | def embedding(texts): 12 | config = conf.config.embedding 13 | payload = { 14 | "model": config.model, 15 | "input": texts, 16 | "encoding_format": "float", 17 | } 18 | headers = { 19 | "accept": "application/json", 20 | "content-type": "application/json", 21 | "authorization": f"Bearer {config.api_key}", 22 | } 23 | response = requests.post(config.api_url, json=payload, headers=headers, timeout=_TIMEOUT) 24 | data = response.json() 25 | if data.get('code'): 26 | raise ValueError("Error occurred during embedding: " + data['message']) 27 | return data["data"], data["usage"]["total_tokens"] 28 | 29 | 30 | def convert_account(account): 31 | """ 32 | Convert an account string to a specific segment. 33 | 34 | Args: 35 | account (str): The account string to convert. 36 | 37 | Returns: 38 | str: The converted account string. 39 | 40 | This function takes an account string and converts it to a specific segment 41 | based on the configuration in conf.config.beancount.account_distinguation_range. 42 | If the account string does not contain any segment, the original account string 43 | is returned. 44 | """ 45 | dist_range = conf.config.beancount.account_distinguation_range 46 | segments = account.split(":") 47 | if isinstance(dist_range, int): 48 | segments = segments[dist_range:dist_range+1] 49 | else: 50 | segments = segments[dist_range[0]:dist_range[1]+1] 51 | if not segments: 52 | return account 53 | return ":".join(segments) 54 | 55 | 56 | def escape_quotes(s): 57 | if not s: 58 | return s 59 | return s.replace('"', '\\"') 60 | 61 | 62 | def convert_to_natural_language(transaction) -> str: 63 | """ 64 | Convert a transaction object to a string representation of natural language for input to RAG. 65 | 66 | Args: 67 | transactions (Transation): A Transaction object. 68 | 69 | Returns: 70 | str: The natural language representation of the transaction. 71 | 72 | The format of the representation is: 73 | `"{payee}" "{description}" "{account1} {account2} ..." [{#tag1} {#tag2} ...]`, 74 | where `{payee}` is the payee of the transaction, `{description}` is the narration, 75 | and `{account1} {account2} ...` is a space-separated list of accounts in the transaction. 76 | The accounts are converted to the most distinguable level as specified in the configuration. 77 | If the transaction has tags, they are appended to the end of the sentence. 78 | """ 79 | payee = f'"{escape_quotes(transaction.payee)}"' 80 | description = f'"{escape_quotes(transaction.narration)}"' 81 | accounts = " ".join([convert_account(posting.account) for posting in transaction.postings]) 82 | sentence = f"{payee} {description} {accounts}" 83 | if transaction.tags: 84 | tags = " ".join(["#" + tag for tag in transaction.tags]) 85 | sentence += f" {tags}" 86 | return sentence 87 | 88 | 89 | def build_tx_db(transactions): 90 | """ 91 | Build a transaction database from the given transactions. This function 92 | consolidates the latest transactions and calculates their embeddings. 93 | The embeddings are stored in a database for future use. 94 | 95 | Args: 96 | transactions (list): A list of Transaction objects representing the 97 | transactions. 98 | 99 | Returns: 100 | int: The total number of tokens used for embedding. 101 | """ 102 | _content_cache = {} 103 | def _read_lines(fname, start, end): 104 | if fname not in _content_cache: 105 | with open(fname) as f: 106 | _content_cache[fname] = f.readlines() 107 | return _content_cache[fname][start-1:end] 108 | 109 | unique_txs = {} 110 | amount = conf.config.embedding.transaction_amount 111 | # Build latest transactions 112 | for entry in reversed(transactions): 113 | if not isinstance(entry, Transaction): 114 | continue 115 | sentence = convert_to_natural_language(entry) 116 | if sentence is None: 117 | continue 118 | if sentence in unique_txs: 119 | unique_txs[sentence]["occurance"] += 1 120 | continue 121 | fname = entry.meta['filename'] 122 | start_lineno = entry.meta['lineno'] 123 | end_lineno = max(p.meta['lineno'] for p in entry.postings) 124 | unique_txs[sentence] = { 125 | "sentence": sentence, 126 | "hash": hash_entry(entry), 127 | "occurance": 1, 128 | "content": "".join(_read_lines(fname, start_lineno, end_lineno)), 129 | } 130 | if len(unique_txs) >= amount: 131 | break 132 | # Build embedding by group 133 | total_usage = 0 134 | unique_txs_list = list(unique_txs.values()) 135 | 136 | for i in range(0, len(unique_txs_list), 32): 137 | sentence = [s['sentence'] for s in unique_txs_list[i:i+32]] 138 | embed, usage = embedding(sentence) 139 | for s, e in zip(unique_txs_list[i:i+32], embed): 140 | s["embedding"] = e["embedding"] 141 | total_usage += usage 142 | 143 | build_db(unique_txs_list) 144 | conf.logger.info("Total token usage: %d", total_usage) 145 | return total_usage 146 | 147 | 148 | def query_txs(query): 149 | """ 150 | Query transactions based on the given query string. 151 | 152 | Args: 153 | query (str): The query string to search for. 154 | 155 | Returns: 156 | list: A list of matched transactions. The length of the list is determined 157 | by the `output_amount` configuration. 158 | """ 159 | candidates = conf.config.embedding.candidates or 3 160 | output_amount = conf.config.embedding.output_amount or 1 161 | match = query_by_embedding(embedding([query])[0][0]["embedding"], query, candidates) 162 | if match: 163 | return match[:output_amount] 164 | return [] 165 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import './style.css'; 2 | import './icons.js'; 3 | import { initLocale } from './i18n.js'; 4 | import { fetchMessages, sendChatMessage, submitTransaction, cloneTransaction, submitTransactionWithAmount as cloneTransactionWithAmount } from './api.js'; 5 | import { 6 | buildSubmittedElement, appendMessage, showErrorDialog, switchTab, markButtonSuccess, 7 | showTransactionDialog, hideTransactionDialog, showNumpad, hideNumpad, 8 | handleNumpadInput, clearAmountDisplay, resetAmountDisplay 9 | } from './ui.js'; 10 | import { 11 | messageHistory, errorDialog, messageInput, sendButton, submitTransactionButton, transactionText, 12 | cloneTransactionButton, closeDialogButton, messageFavorites, loadingIndicator, transactionDialog, 13 | modifyAmountButton, submitWithAmountButton, numpadContainer, amountDisplay, numpadClearButton, numpadButtons 14 | } from './storage.js'; 15 | import { registerSW } from 'virtual:pwa-register'; 16 | 17 | 18 | // 初始化消息列表 19 | async function initMessages(): Promise { 20 | try { 21 | const { messages, favorites } = await fetchMessages(); 22 | messages.forEach(async msg => { 23 | await appendMessage(messageHistory, msg); 24 | }); 25 | favorites.forEach(async msg => { 26 | await appendMessage(messageFavorites, msg); 27 | }); 28 | messageHistory.scrollTop = messageHistory.scrollHeight; 29 | } catch (error) { 30 | console.error('Error fetching messages:', error); 31 | await showErrorDialog(errorDialog, 'Failed to fetch messages. Please check console for details.'); 32 | } 33 | } 34 | 35 | // 发送消息 36 | async function handleSendMessage() { 37 | const message = messageInput.value; 38 | if (message === '') { 39 | return; 40 | } 41 | 42 | sendButton.disabled = true; 43 | try { 44 | const data = await sendChatMessage(message); 45 | messageInput.value = ''; 46 | await appendMessage(messageHistory, data); 47 | switchTab('history'); 48 | messageHistory.scrollTop = messageHistory.scrollHeight; 49 | showTransactionDialog(data.id); 50 | } catch (error) { 51 | console.error('Error sending message:', error); 52 | await showErrorDialog(errorDialog, error.message || 'Failed to send message. Please check console for details.'); 53 | } finally { 54 | sendButton.disabled = false; 55 | } 56 | } 57 | 58 | // 处理交易操作 59 | async function handleTransactionAction(action: 'submit' | 'clone' | 'clone_with_amount', msgId: string, button: HTMLButtonElement, amount?: string): Promise { 60 | button.disabled = true; 61 | try { 62 | if (action === 'submit') { 63 | await submitTransaction(Number(msgId)); 64 | } else if (action === 'clone') { 65 | await cloneTransaction(Number(msgId)); 66 | } else if (action === 'clone_with_amount' && amount) { 67 | await cloneTransactionWithAmount(Number(msgId), amount); 68 | } 69 | 70 | // 更新消息状态 71 | const messageElements = document.querySelectorAll(`[data-msg-id="${msgId}"]>div.right-container`); 72 | messageElements.forEach((ele: HTMLDivElement) => { 73 | if (ele.querySelector('.ele-check') === null) { 74 | ele.insertBefore(buildSubmittedElement(), ele.firstChild); 75 | } 76 | }); 77 | 78 | await markButtonSuccess(button); 79 | hideTransactionDialog(); 80 | } catch (error) { 81 | console.error(`Error during transaction ${action}:`, error); 82 | await showErrorDialog(errorDialog, error.message || `Transaction ${action} failed. Please check console for details.`); 83 | } finally { 84 | button.disabled = false; 85 | } 86 | } 87 | 88 | // 注册事件监听器 89 | function registerEventListeners(): void { 90 | // 发送消息 91 | sendButton.addEventListener('click', handleSendMessage); 92 | messageInput.addEventListener('keydown', async (event) => { 93 | if (event.key === 'Enter') { 94 | await handleSendMessage(); 95 | } 96 | }); 97 | 98 | // 交易操作 99 | submitTransactionButton.addEventListener('click', async () => { 100 | const msgId = transactionText.dataset.msgId as string; 101 | await handleTransactionAction('submit', msgId, submitTransactionButton); 102 | }); 103 | 104 | cloneTransactionButton.addEventListener('click', async () => { 105 | const msgId = transactionText.dataset.msgId as string; 106 | await handleTransactionAction('clone', msgId, cloneTransactionButton); 107 | }); 108 | 109 | // 修改金额按钮 110 | modifyAmountButton.addEventListener('click', () => { 111 | showNumpad(); 112 | }); 113 | 114 | // 提交修改金额 115 | submitWithAmountButton.addEventListener('click', async () => { 116 | const msgId = transactionText.dataset.msgId as string; 117 | const amount = amountDisplay.textContent; 118 | if (amount) { 119 | await handleTransactionAction('clone_with_amount', msgId, submitWithAmountButton, amount); 120 | } 121 | }); 122 | 123 | // 数字键盘按钮 124 | numpadButtons.forEach(button => { 125 | button.addEventListener('click', () => { 126 | handleNumpadInput(button.textContent); 127 | }); 128 | }); 129 | 130 | // 清除按钮 131 | numpadClearButton.addEventListener('click', clearAmountDisplay); 132 | 133 | // 对话框操作 134 | closeDialogButton.addEventListener('click', () => { 135 | hideTransactionDialog(); 136 | }); 137 | 138 | document.addEventListener('click', (event: MouseEvent) => { 139 | if (!transactionDialog.contains(event.target as Node | null) && !((event.target as HTMLElement)?.classList.contains('message'))) { 140 | hideTransactionDialog(); 141 | } 142 | }); 143 | 144 | document.addEventListener('keydown', (event) => { 145 | if (event.key === 'Escape') { 146 | hideTransactionDialog(); 147 | } 148 | }); 149 | 150 | // 标签页切换 151 | document.querySelectorAll('[data-tab-button]').forEach(btn => { 152 | btn.addEventListener('click', () => { 153 | switchTab(btn.dataset.tabButton); 154 | }); 155 | }); 156 | } 157 | 158 | // 初始化应用 159 | document.addEventListener('DOMContentLoaded', async () => { 160 | registerEventListeners(); 161 | 162 | loadingIndicator.classList.replace('hidden', 'flex'); 163 | await Promise.all([initLocale(), initMessages()]); 164 | loadingIndicator.classList.replace('flex', 'hidden'); 165 | }); 166 | 167 | // Service worker 配置 168 | const intervalMS = 60 * 60 * 1000; 169 | const updateSW = registerSW({ 170 | onRegistered: (r) => { 171 | if (r) { 172 | setInterval(() => { 173 | r.update(); 174 | }, intervalMS); 175 | } 176 | }, 177 | onNeedRefresh: () => { 178 | if (window.confirm(`There is a new version of this app available. Do you want to update?`)) { 179 | updateSW(); 180 | } 181 | }, 182 | onOfflineReady: () => { 183 | showErrorDialog(errorDialog, 'This app is offline.'); 184 | } 185 | }); -------------------------------------------------------------------------------- /bots/mattermost_bot.py: -------------------------------------------------------------------------------- 1 | import click 2 | from datetime import datetime, timedelta 3 | from conf.i18n import gettext as _ 4 | from mmpy_bot import Bot, Settings 5 | from mmpy_bot import Plugin, listen_to, listen_webhook 6 | from mmpy_bot.plugins.base import PluginManager 7 | from mmpy_bot.driver import Driver 8 | from mmpy_bot import Message, WebHookEvent 9 | from fava.util.date import parse_date 10 | from beancount.core.inventory import Inventory 11 | from bean_utils.bean import bean_manager 12 | from bots import controller 13 | import conf 14 | 15 | 16 | OWNER_NAME = conf.config.bot.mattermost.owner_user 17 | 18 | 19 | def render_table(header, rows): 20 | data = [header] 21 | for row in rows: 22 | row_data = list(row) 23 | for i, obj in enumerate(row): 24 | if isinstance(obj, Inventory): 25 | row_data[i] = obj.to_string() 26 | data.append(row_data) 27 | 28 | column = 1 29 | if rows: 30 | column = max(column, max(len(r) for r in rows)) 31 | else: 32 | data.append([""]) 33 | header_line = "|" + "|".join(data[0]) + "|" 34 | table_line = "|" + "-|" * column 35 | data_lines = ["|".join(["", *r, ""]) for r in data[1:]] 36 | 37 | return "\n".join([header_line, table_line, *data_lines]) 38 | 39 | 40 | class BeanBotPlugin(Plugin): 41 | def initialize(self, driver: Driver, plugin_manager: PluginManager, settings: Settings): 42 | super().initialize(driver, plugin_manager, settings) 43 | self.webhook_host_url = settings.WEBHOOK_HOST_URL 44 | self.webhook_host_port = settings.WEBHOOK_HOST_PORT 45 | return self 46 | 47 | def gen_hook(self, action): 48 | return f"{self.webhook_host_url}:{self.webhook_host_port}/hooks/{action}" 49 | 50 | def gen_action(self, id_, name, trx): 51 | return { 52 | "id": id_, 53 | "name": name, 54 | "integration": { 55 | "url": self.gen_hook(id_), 56 | "context": { 57 | "trx": trx, 58 | "choice": name, 59 | } 60 | } 61 | } 62 | 63 | @listen_to(r"^-?[\d.]+ ", direct_only=True, allowed_users=[OWNER_NAME]) 64 | async def render(self, message: Message): 65 | resp = controller.render_txs(message.text) 66 | if isinstance(resp, controller.ErrorMessage): 67 | self.driver.reply_to(message, "", props={ 68 | "attachments": [ 69 | { 70 | "text": resp.content, 71 | "color": "#ffc107" 72 | } 73 | ] 74 | }) 75 | return 76 | for tx in resp: 77 | tx_content = tx.content 78 | self.driver.reply_to(message, f"`{tx_content}`", props={ 79 | "attachments": [ 80 | { 81 | "actions": [ 82 | self.gen_action("submit", _("Submit"), tx_content), 83 | self.gen_action("cancel", _("Cancel"), tx_content), 84 | ] 85 | } 86 | ] 87 | }) 88 | 89 | @listen_webhook("^(submit|cancel)$") 90 | async def submit_listener(self, event: WebHookEvent): 91 | post_id = event.body["post_id"] 92 | trx = event.body["context"]["trx"] 93 | webhook_id = event.webhook_id 94 | 95 | if webhook_id == "submit": 96 | reaction = "white_check_mark" 97 | bean_manager.commit_trx(trx.strip()) 98 | conf.logger.info("Commit transaction: %s\n", trx) 99 | else: 100 | reaction = "wastebasket" 101 | conf.logger.info("Cancel transaction") 102 | 103 | self.driver.respond_to_web(event, { 104 | "update": { 105 | "message": f"`{trx}`", 106 | "props": {} 107 | }, 108 | }) 109 | self.driver.react_to(Message({ 110 | "data": {"post": {"id": post_id}}, 111 | }), reaction) 112 | 113 | @listen_to("bill", direct_only=True, allowed_users=[OWNER_NAME]) 114 | @click.command(help=_("Query account changes")) 115 | @click.option("-l", "--level", default=2, type=int) 116 | @click.argument("date", nargs=-1, type=str) 117 | def bill(self, message: Message, level: int, date: str): 118 | if date: 119 | start, end = parse_date(date[0]) 120 | else: 121 | start = datetime.now().astimezone().date() 122 | end = start + timedelta(days=1) 123 | if start is None and end is None: 124 | self.driver.reply_to(message, f"Wrong args: {date}") 125 | 126 | resp_table = controller.fetch_bill(start, end, level) 127 | result = render_table(resp_table.headers, resp_table.rows) 128 | self.driver.reply_to(message, f"**{resp_table.title}**\n\n{result}") 129 | 130 | @listen_to("expense", direct_only=True, allowed_users=[OWNER_NAME]) 131 | @click.command(help=_("Query expenses")) 132 | @click.option("-l", "--level", default=2, type=int) 133 | @click.argument("args", nargs=-1, type=str) 134 | def expense(self, message: Message, level: int, args: str): 135 | if args: 136 | start, end = parse_date(args[0]) 137 | else: 138 | start = datetime.now().astimezone().date() 139 | end = start + timedelta(days=1) 140 | 141 | if start is None and end is None: 142 | self.driver.reply_to(message, f"Wrong args: {args}") 143 | return 144 | 145 | resp_table = controller.fetch_expense(start, end, level) 146 | result = render_table(resp_table.headers, resp_table.rows) 147 | self.driver.reply_to(message, f"**{resp_table.title}**\n\n{result}") 148 | 149 | @listen_to("build", direct_only=True, allowed_users=[OWNER_NAME]) 150 | def build_db(self, message: Message): 151 | msg = controller.build_db() 152 | self.driver.reply_to(message, msg.content) 153 | 154 | 155 | def run_bot(): 156 | mm_conf = conf.config.bot.mattermost 157 | bot = Bot( 158 | settings=Settings( 159 | MATTERMOST_URL=mm_conf.server_url, 160 | MATTERMOST_PORT=mm_conf.server_port, 161 | BOT_TOKEN=mm_conf.bot_token, 162 | BOT_TEAM=mm_conf.bot_team, 163 | SSL_VERIFY=mm_conf.ssl_verify, 164 | 165 | WEBHOOK_HOST_ENABLED=True, 166 | WEBHOOK_HOST_PORT=mm_conf.webhook_host_port, 167 | WEBHOOK_HOST_URL=mm_conf.webhook_host_url, 168 | ), # Either specify your settings here or as environment variables. 169 | plugins=[BeanBotPlugin()], # Add your own plugins here. 170 | ) 171 | conf.logger.info("Beancount bot start") 172 | bot.run() 173 | -------------------------------------------------------------------------------- /bots/telegram_bot.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import time 3 | from conf.i18n import gettext as _ 4 | from datetime import timedelta, datetime 5 | import telegram 6 | from telegram import Update 7 | from telegram.ext import ( 8 | Application, filters, 9 | MessageHandler, CommandHandler, CallbackQueryHandler 10 | ) 11 | from fava.util.date import parse_date 12 | from bean_utils.bean import bean_manager 13 | from bots import controller 14 | import conf 15 | 16 | 17 | OWNER_ID = conf.config.bot.telegram.chat_id 18 | 19 | 20 | async def start(update, context): 21 | now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S") 22 | uptime = timedelta(seconds=time.monotonic()) 23 | await update.message.reply_text(text=f"Now: {now}\nUptime: {uptime}") 24 | 25 | 26 | def owner_required(func): 27 | async def wrapped(update, context): 28 | chat_id = update.effective_chat.id 29 | if chat_id != OWNER_ID: 30 | return 31 | await func(update, context) 32 | 33 | return wrapped 34 | 35 | 36 | def _render_tg_table(headers, rows): 37 | MARGIN = 2 38 | max_widths = list(map(len, headers)) 39 | for row in rows: 40 | for i, cell in enumerate(row): 41 | max_widths[i] = max(len(str(cell)), max_widths[i]) 42 | # Write header 43 | table = [] 44 | raw_row = [] 45 | for i, header in enumerate(headers): 46 | raw_row.append(f"{header}{' ' * (max_widths[i] - len(header) + MARGIN)}") 47 | table.append(raw_row) 48 | table.append("-" * (sum(max_widths) + MARGIN * (len(max_widths) - 1))) 49 | # Write rows 50 | for row in rows: 51 | raw_row = [] 52 | for i, cell in enumerate(row): 53 | raw_row.append(f"{cell}{' ' * (max_widths[i] - len(str(cell)) + MARGIN)}") 54 | table.append(raw_row) 55 | 56 | return "\n".join("".join(row) for row in table) 57 | 58 | 59 | def _escape_md2(text): 60 | return text.replace("-", "\\-").replace("*", "\\*") 61 | 62 | 63 | def _parse_bill_args(args): 64 | root_level = 2 65 | if args: 66 | start, end = parse_date(args[0]) 67 | if len(args) > 1: 68 | root_level = int(args[1]) 69 | else: 70 | start = datetime.now().astimezone().date() 71 | end = start + timedelta(days=1) 72 | return start, end, root_level 73 | 74 | 75 | @owner_required 76 | async def bill(update, context): 77 | start, end, root_level = _parse_bill_args(context.args) 78 | if start is None and end is None: 79 | await update.message.reply_text(f"Wrong args: {context.args}") 80 | return 81 | resp_table = controller.fetch_bill(start, end, root_level) 82 | result = _render_tg_table(resp_table.headers, resp_table.rows) 83 | message = update.message 84 | if update.message is None: 85 | message = update.edited_message 86 | await message.reply_text(_escape_md2(f"{resp_table.title}\n```\n{result}\n```"), 87 | parse_mode="MarkdownV2") 88 | 89 | 90 | @owner_required 91 | async def expense(update, context): 92 | start, end, root_level = _parse_bill_args(context.args) 93 | if start is None and end is None: 94 | await update.message.reply_text(f"Wrong args: {context.args}") 95 | return 96 | resp_table = controller.fetch_expense(start, end, root_level) 97 | result = _render_tg_table(resp_table.headers, resp_table.rows) 98 | message = update.message 99 | if update.message is None: 100 | message = update.edited_message 101 | await message.reply_text(_escape_md2(f"{resp_table.title}\n```\n{result}\n```"), 102 | parse_mode="MarkdownV2") 103 | 104 | 105 | _button_list = [ 106 | telegram.InlineKeyboardButton(_("Submit"), callback_data="submit"), 107 | telegram.InlineKeyboardButton(_("Cancel"), callback_data="cancel"), 108 | ] 109 | _pending_txs_reply_markup = telegram.InlineKeyboardMarkup([_button_list]) 110 | 111 | 112 | @owner_required 113 | async def render(update, context): 114 | message = update.message 115 | if update.message is None: 116 | message = update.edited_message 117 | 118 | resp = controller.render_txs(message.text) 119 | if isinstance(resp, controller.ErrorMessage): 120 | await update.message.reply_text(resp.content, reply_to_message_id=message.message_id) 121 | return 122 | 123 | for tx in resp: 124 | await update.message.reply_text(tx.content, reply_to_message_id=message.message_id, 125 | reply_markup=_pending_txs_reply_markup) 126 | 127 | 128 | @owner_required 129 | async def callback(update, context): 130 | message = update.callback_query.message 131 | trx = message.text 132 | choice = update.callback_query.data 133 | query = update.callback_query 134 | await query.answer() 135 | 136 | if choice == "submit": 137 | result_msg = _("Submitted ✅") 138 | bean_manager.commit_trx(trx) 139 | conf.logger.info("Commit transaction: %s\n", trx) 140 | else: 141 | result_msg = _("Cancelled ❌") 142 | conf.logger.info("Cancel transaction") 143 | 144 | if result_msg: 145 | await query.edit_message_text(text=f"{trx}\n\n{result_msg}") 146 | 147 | 148 | @owner_required 149 | async def build_db(update, context): 150 | msg = controller.build_db() 151 | await update.message.reply_text(msg.content) 152 | 153 | 154 | @owner_required 155 | async def clone_txs(update, context): 156 | # Fetch ref message 157 | message = update.message.reply_to_message 158 | if message is None: 159 | await update.message.reply_text("Please specify the transaction", reply_to_message_id=message.message_id) 160 | return 161 | # Fetch original message 162 | resp = controller.clone_txs(message.text) 163 | if isinstance(resp, controller.ErrorMessage): 164 | await update.message.reply_text(resp.content, reply_to_message_id=message.message_id) 165 | else: 166 | await update.message.reply_text(resp.content, reply_to_message_id=message.message_id, 167 | reply_markup=_pending_txs_reply_markup) 168 | 169 | 170 | def run_bot(): 171 | application = Application.builder().token(conf.config.bot.telegram.token).build() 172 | 173 | handlers = [ 174 | CommandHandler('start', start), 175 | CommandHandler('bill', bill), 176 | CommandHandler('expense', expense), 177 | CommandHandler('build', build_db, has_args=False), 178 | CommandHandler('clone', clone_txs, filters=filters.REPLY, has_args=False), 179 | MessageHandler(filters.TEXT & (~filters.COMMAND), render), 180 | CallbackQueryHandler(callback), 181 | ] 182 | for handler in handlers: 183 | application.add_handler(handler) 184 | 185 | conf.logger.info("Beancount bot start") 186 | application.run_polling(allowed_updates=Update.ALL_TYPES) 187 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Beancount Bot 12 | 13 | 14 | 15 |
16 | 17 |
18 |
19 | 22 | 25 |
26 |
27 | 28 | 29 |
30 |
31 |
32 | 33 | 36 | 37 | 38 |
39 |
40 | 41 | 85 | 86 | 87 |
88 |
89 | 90 |
91 | 92 |
93 |
94 | 97 |
98 |
99 |
100 |
101 | 102 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /frontend/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Beancount Bot 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 |
20 | 23 | 26 |
27 |
28 | 29 | 30 |
31 |
32 |
33 | 34 | 37 | 38 | 39 |
40 |
41 | 42 | 86 | 87 | 88 |
89 |
90 | 91 |
92 | 93 |
94 |
95 | 98 |
99 |
100 |
101 |
102 | 103 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /bots/web_bot.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from pathlib import Path 3 | from decimal import Decimal, InvalidOperation 4 | import requests 5 | from bottle import Bottle, request, static_file, response 6 | from bots import controller 7 | import conf 8 | from bean_utils.bean import bean_manager 9 | from conf.i18n import gettext as _ 10 | 11 | app = Bottle() 12 | 13 | # Database setup 14 | DATABASE = conf.config.bot.web.chat_db 15 | 16 | def get_db(): 17 | db = getattr(request, 'database', None) 18 | if db is None: 19 | db = request.database = sqlite3.connect(DATABASE) 20 | return db 21 | 22 | @app.hook('before_request') 23 | def db_connect(): 24 | request.db = get_db() 25 | 26 | @app.hook('after_request') 27 | def db_close(): 28 | if hasattr(request, 'db'): 29 | request.db.close() 30 | 31 | def init_db(): 32 | with sqlite3.connect(DATABASE) as db: 33 | cursor = db.cursor() 34 | cursor.execute(''' 35 | CREATE TABLE IF NOT EXISTS messages ( 36 | id INTEGER PRIMARY KEY AUTOINCREMENT, 37 | message TEXT NOT NULL, 38 | transaction_text TEXT, 39 | status TINYINT default 0 40 | ) 41 | ''') 42 | db.commit() 43 | try: 44 | cursor = db.cursor() 45 | cursor.execute(''' 46 | ALTER TABLE messages ADD COLUMN favorite TINYINT default 0 47 | ''') 48 | db.commit() 49 | except sqlite3.OperationalError: 50 | pass 51 | 52 | 53 | @app.route('/api/messages') 54 | def list_messages(): 55 | cursor = request.db.cursor() 56 | cursor.execute("SELECT id, message, transaction_text, status, favorite FROM messages ORDER BY id DESC limit 20") 57 | messages = [] 58 | for (id_, message, trx, status, favorite) in reversed(cursor.fetchall()): 59 | messages.append({ 60 | 'id': id_, 61 | 'message': message, 62 | 'transaction_text': trx, 63 | 'status': 'submitted' if status == 1 else 'pending', 64 | 'favorite': bool(favorite), 65 | }) 66 | 67 | collection_cursor = request.db.cursor() 68 | collection_cursor.execute("SELECT id, message, transaction_text, status, favorite FROM messages WHERE favorite = 1 ORDER BY id DESC") 69 | favorites = [] 70 | for (id_, message, trx, status, favorite) in reversed(collection_cursor.fetchall()): 71 | favorites.append({ 72 | 'id': id_, 73 | 'message': message, 74 | 'transaction_text': trx, 75 | 'status': 'submitted' if status == 1 else 'pending', 76 | 'favorite': bool(favorite), 77 | }) 78 | return { 79 | 'messages': messages, 80 | 'favorites': favorites, 81 | } 82 | 83 | 84 | @app.route('/api/favorite', method='POST') 85 | def collect(): 86 | message_id = request.json.get('id') 87 | status = int(request.json.get('favorite')) 88 | if not message_id: 89 | response.status = 400 90 | return {'error': 'Message ID is required'} 91 | 92 | cursor = request.db.cursor() 93 | cursor.execute("UPDATE messages SET favorite = ? WHERE id = ?", (status, message_id)) 94 | request.db.commit() 95 | return {'success': True} 96 | 97 | 98 | @app.route('/api/delete', method='POST') 99 | def delete_trx(): 100 | message_id = request.json.get('id') 101 | if not message_id: 102 | response.status = 400 103 | return {'error': 'Message ID is required'} 104 | 105 | cursor = request.db.cursor() 106 | cursor.execute("DELETE FROM messages WHERE id = ?", (message_id, )) 107 | request.db.commit() 108 | return {'success': True} 109 | 110 | 111 | @app.route('/api/chat', method='POST') 112 | def chat(): 113 | message = request.json.get('message') 114 | if not message: 115 | response.status = 400 116 | return {'error': _('Message should not be empty.')} 117 | try: 118 | Decimal(message.split()[0]) 119 | except InvalidOperation: 120 | response.status = 400 121 | return {'error': _('Message must start with a number.')} 122 | 123 | try: 124 | resp = controller.render_txs(message) 125 | except (ValueError, requests.exceptions.RequestException) as e: 126 | response.status = 500 127 | return {'error': repr(e)} 128 | if isinstance(resp, controller.ErrorMessage): 129 | response.status = 400 130 | return {'error': resp.content} 131 | 132 | transaction_text = resp[0].content 133 | cursor = request.db.cursor() 134 | cursor.execute("INSERT INTO messages (message, transaction_text) VALUES (?, ?)", (message, transaction_text)) 135 | request.db.commit() 136 | last_id = cursor.lastrowid 137 | 138 | return { 139 | 'message': message, 140 | 'transaction_text': transaction_text, 141 | 'id': last_id, 142 | 'status': 'pending', 143 | } 144 | 145 | 146 | @app.route('/api/submit', method='POST') 147 | def submit(): 148 | message_id = request.json.get('id') 149 | if not message_id: 150 | response.status = 400 151 | return {'error': 'Message ID is required'} 152 | 153 | cursor = request.db.cursor() 154 | cursor.execute("SELECT transaction_text FROM messages WHERE id = ?", (message_id,)) 155 | row = cursor.fetchone() 156 | if not row: 157 | response.status = 404 158 | return {'error': 'Message not found'} 159 | 160 | trx = row[0] 161 | bean_manager.commit_trx(trx.strip()) 162 | cursor.execute("UPDATE messages SET status = 1 WHERE id = ?", (message_id,)) 163 | request.db.commit() 164 | return {'success': True} 165 | 166 | 167 | @app.route('/api/clone', method='POST') 168 | def clone_txs(): 169 | message_id = request.json.get('id') 170 | if not message_id: 171 | response.status = 400 172 | return {'error': 'Message ID is required'} 173 | 174 | cursor = request.db.cursor() 175 | cursor.execute("SELECT transaction_text FROM messages WHERE id = ?", (message_id,)) 176 | row = cursor.fetchone() 177 | if not row: 178 | response.status = 404 179 | return {'error': 'Message not found'} 180 | 181 | amount = request.json.get("amount") 182 | if amount is not None: 183 | try: 184 | amount = Decimal(amount) 185 | except InvalidOperation: 186 | response.status = 400 187 | return {'error': 'Amount is invalid'} 188 | 189 | trx = row[0] 190 | resp = controller.clone_txs(trx.strip(), amount) 191 | if isinstance(resp, controller.ErrorMessage): 192 | response.status = 500 193 | return { 194 | 'success': False, 195 | 'error': resp.content 196 | } 197 | bean_manager.commit_trx(resp.content) 198 | return { 199 | 'success': True, 200 | 'data': resp.content 201 | } 202 | 203 | @app.route('/api/config') 204 | def config_json(): 205 | # Set language to invode i18n language detector 206 | lang = conf.config.get("language") 207 | if lang: 208 | response.set_cookie("i18next", lang, expires=30*24*60*60) 209 | return { 210 | "lang": lang, 211 | } 212 | 213 | _root_path = Path(__file__).resolve().parent.parent 214 | 215 | @app.route('/') 216 | def serve_frontend(): 217 | response = static_file('index.html', root=Path(_root_path) / 'frontend/dist') 218 | # Set language to invode i18n language detector 219 | lang = conf.config.get("language") 220 | if lang: 221 | response.set_cookie("i18next", lang, expires=30*24*60*60) 222 | return response 223 | 224 | @app.route('/') 225 | def serve_static(filename): 226 | response = static_file(filename, root=Path(_root_path) / 'frontend/dist') 227 | if filename == "index.html": 228 | # Set language to invode i18n language detector 229 | lang = conf.config.get("language") 230 | if lang: 231 | response.set_cookie("i18next", lang, expires=30*24*60*60) 232 | return response 233 | 234 | def run_bot(): 235 | init_db() 236 | web_conf = conf.config.bot.web 237 | app.run(host=web_conf.host, port=web_conf.port, 238 | debug=web_conf.get("debug", False), 239 | reloader=web_conf.get("reloader", False)) 240 | -------------------------------------------------------------------------------- /bean_utils/bean_test.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | import shutil 4 | from pathlib import Path 5 | import requests 6 | import pytest 7 | from conf.conf_test import load_config_from_dict, clear_config 8 | from beancount.parser import parser 9 | from bean_utils import bean, vec_query 10 | 11 | 12 | today = str(datetime.now().astimezone().date()) 13 | 14 | class MockResponse: 15 | def __init__(self, data): 16 | self._data = data 17 | 18 | def json(self): 19 | return self._data 20 | 21 | 22 | def mock_post(data): 23 | def _wrapped(*args, **kwargs): 24 | return MockResponse(data) 25 | return _wrapped 26 | 27 | 28 | @pytest.fixture 29 | def mock_config(tmp_path): 30 | conf_data = { 31 | "embedding": { 32 | "enable": False, 33 | "db_store_folder": tmp_path, 34 | }, 35 | "beancount": { 36 | "filename": "testdata/example.bean", 37 | "currency": "USD", 38 | "account_distinguation_range": [2, 3], 39 | } 40 | } 41 | config = load_config_from_dict(conf_data) 42 | yield config 43 | clear_config() 44 | 45 | 46 | def test_load(mock_config): 47 | manager = bean.BeanManager(mock_config.beancount.filename) 48 | assert manager is not None 49 | entries = manager.entries 50 | assert len(entries) > 0 51 | 52 | 53 | def test_account_search(mock_config): 54 | manager = bean.BeanManager(mock_config.beancount.filename) 55 | accounts = manager.accounts 56 | assert len(accounts) > 0 57 | 58 | # Find account by full name 59 | exp_account = manager.find_account("Assets:US:BofA:Checking") 60 | assert exp_account == "Assets:US:BofA:Checking" 61 | # Find account by partial content 62 | exp_account = manager.find_account("ETrade:PnL") 63 | assert exp_account == "Income:US:ETrade:PnL" 64 | 65 | # Find account by payee 66 | # Select by missing unit 67 | exp_account = manager.find_account_by_payee("Chichipotle") 68 | assert exp_account == "Expenses:Food:Restaurant" 69 | # Select by account type 70 | exp_account = manager.find_account_by_payee("China Garden") 71 | assert exp_account == "Expenses:Food:Restaurant" 72 | 73 | 74 | def assert_txs_equal(tx1_str, tx2_str): 75 | if isinstance(tx1_str, str): 76 | tx1 = parser.parse_string(tx1_str)[0][0] 77 | else: 78 | tx1 = tx1_str 79 | if isinstance(tx2_str, str): 80 | tx2 = parser.parse_string(tx2_str)[0][0] 81 | else: 82 | tx2 = tx2_str 83 | 84 | def clean_meta(tx): 85 | keys = list(tx.meta.keys()) 86 | for key in keys: 87 | del tx.meta[key] 88 | for p in tx.postings: 89 | keys = list(p.meta.keys()) 90 | for key in keys: 91 | del p.meta[key] 92 | return tx 93 | 94 | assert clean_meta(tx1) == clean_meta(tx2) 95 | 96 | 97 | def test_build_txs(mock_config): 98 | manager = bean.BeanManager(mock_config.beancount.filename) 99 | 100 | # Test basic transaction 101 | args = ["10.00", "Assets:US:BofA:Checking", "Expenses:Food:Restaurant", "McDonalds", "Big Mac"] 102 | trx = manager.build_trx(args) 103 | assert trx != "" 104 | # Trick: The first \n is needed for further match on the line number 105 | exp_trx = f""" 106 | {today} * "McDonalds" "Big Mac" 107 | Assets:US:BofA:Checking -10.00 USD 108 | Expenses:Food:Restaurant 109 | """ 110 | assert_txs_equal(trx, exp_trx) 111 | 112 | # Test find account by payee 113 | args = ["23.4", "BofA:Checking", "Kin Soy", "Eating"] 114 | trx = manager.build_trx(args) 115 | assert trx != "" 116 | exp_trx = f""" 117 | {today} * "Kin Soy" "Eating" 118 | Assets:US:BofA:Checking -23.40 USD 119 | Expenses:Food:Restaurant 120 | """ 121 | assert_txs_equal(trx, exp_trx) 122 | 123 | # Test generate with tags 124 | args = ["23.4", "BofA:Checking", "Kin Soy", "Eating", "#tag1", "#tag2"] 125 | trx = manager.build_trx(args) 126 | assert trx != "" 127 | exp_trx = f""" 128 | {today} * "Kin Soy" "Eating" #tag1 #tag2 129 | Assets:US:BofA:Checking -23.40 USD 130 | Expenses:Food:Restaurant 131 | """ 132 | assert_txs_equal(trx, exp_trx) 133 | 134 | # Text account not found 135 | with pytest.raises(ValueError, match=r"Account .+ not found"): 136 | manager.build_trx(["10.00", "ICBC:Checking", "NotFound", "McDonalds", "Big Mac"]) 137 | with pytest.raises(ValueError, match=r"Account .+ not found"): 138 | manager.build_trx(["10.00", "BofA:Checking", "McDonalds", "Big Mac"]) 139 | 140 | 141 | def test_generate_trx(mock_config): 142 | manager = bean.BeanManager(mock_config.beancount.filename) 143 | 144 | # Test basic generation 145 | args = '23.4 BofA:Checking "Kin Soy" Eating #tag1 #tag2' 146 | trxs = manager.generate_trx(args) 147 | assert len(trxs) == 1 148 | today = str(datetime.now().astimezone().date()) 149 | exp_trx = f""" 150 | {today} * "Kin Soy" "Eating" #tag1 #tag2 151 | Assets:US:BofA:Checking -23.40 USD 152 | Expenses:Food:Restaurant 153 | """ 154 | assert_txs_equal(trxs[0], exp_trx) 155 | 156 | with pytest.raises(ValueError, match=r"Account .+ not found"): 157 | manager.generate_trx("10.00 ICBC:Checking NotFound McDonalds 'Big Mac'") 158 | 159 | 160 | def mock_embedding(texts): 161 | from vec_db.json_vec_db_test import easy_embedding 162 | return [{ 163 | "embedding": easy_embedding(text) 164 | } for text in texts], len(texts) 165 | 166 | 167 | def test_generate_trx_with_vector_db(mock_config, monkeypatch): 168 | # Test vector DB fallback 169 | mock_config["embedding"] = { 170 | "enable": True, 171 | "transaction_amount": 100, 172 | "candidates": 3, 173 | "output_amount": 2, 174 | } 175 | def _mock_embedding_post(*args, json, **kwargs): 176 | result, tokens = mock_embedding(json["input"]) 177 | return MockResponse({ 178 | "data": result, 179 | "usage": {"total_tokens": tokens}, 180 | }) 181 | monkeypatch.setattr(requests, "post", _mock_embedding_post) 182 | 183 | manager = bean.BeanManager(mock_config.beancount.filename) 184 | vec_query.build_tx_db(manager.entries) 185 | trx = manager.generate_trx('10.00 "Kin Soy", "Eating"') 186 | # The match effect is not garanteed in this test due to incorrect embedding implementation 187 | assert len(trx) == 2 188 | exp = f""" 189 | {today} * "Verizon Wireless" "" 190 | Assets:US:BofA:Checking -10.00 USD 191 | Expenses:Home:Phone 192 | """ 193 | assert_txs_equal(trx[0], exp) 194 | exp = f""" 195 | {today} * "Wine-Tarner Cable" "" 196 | Assets:US:BofA:Checking -10.00 USD 197 | Expenses:Home:Internet 198 | """ 199 | assert_txs_equal(trx[1], exp) 200 | 201 | 202 | def test_generate_trx_with_rag(mock_config, monkeypatch): 203 | exp_trx = f""" 204 | {today} * "Kin Soy" "Eating" #tag1 #tag2 205 | Assets:US:BofA:Checking -23.40 USD 206 | Expenses:Food:Restaurant 207 | """ 208 | mock_config.update({ 209 | "embedding": { 210 | "enable": True, 211 | "transaction_amount": 100, 212 | "candidates": 3, 213 | "output_amount": 2, 214 | }, 215 | "rag": { 216 | "enable": True 217 | } 218 | }) 219 | monkeypatch.setattr(vec_query, "embedding", mock_embedding) 220 | monkeypatch.setattr(requests, "post", mock_post({"message": {"content": exp_trx}})) 221 | 222 | # Test RAG fallback 223 | manager = bean.BeanManager(mock_config.beancount.filename) 224 | trx = manager.generate_trx('10.00 "Kin Soy", "Eating"') 225 | vec_query.build_tx_db(manager.entries) 226 | # The match effect is not garanteed in this test due to incorrect embedding implementation 227 | assert len(trx) == 1 228 | assert_txs_equal(trx[0], exp_trx) 229 | 230 | 231 | def test_run_query(mock_config): 232 | manager = bean.BeanManager(mock_config.beancount.filename) 233 | result = manager.run_query('SELECT SUM(position) WHERE account="Assets:US:BofA:Checking"') 234 | assert result 235 | assert result[1][0][0].to_string() == "(3076.17 USD)" 236 | 237 | 238 | def test_clone_trx(mock_config): 239 | manager = bean.BeanManager(mock_config.beancount.filename) 240 | param = """ 241 | 2023-05-23 * "Kin Soy" "Eating" #tag1 #tag2 242 | Assets:US:BofA:Checking -23.40 USD 243 | Expenses:Food:Restaurant 244 | """ 245 | trx = manager.clone_trx(param) 246 | assert trx != "" 247 | exp_trx = f""" 248 | {today} * "Kin Soy" "Eating" #tag1 #tag2 249 | Assets:US:BofA:Checking -23.40 USD 250 | Expenses:Food:Restaurant 251 | """ 252 | assert_txs_equal(trx, exp_trx) 253 | 254 | trx = manager.clone_trx(param, Decimal("12.3")) 255 | assert trx != "" 256 | exp_trx = f""" 257 | {today} * "Kin Soy" "Eating" #tag1 #tag2 258 | Assets:US:BofA:Checking -12.30 USD 259 | Expenses:Food:Restaurant 260 | """ 261 | assert_txs_equal(trx, exp_trx) 262 | 263 | 264 | def test_parse_args(): 265 | assert bean.parse_args("") == [] 266 | assert bean.parse_args(" ") == [] 267 | assert bean.parse_args("a b c") == ["a", "b", "c"] 268 | assert bean.parse_args("a 'b c' d") == ["a", "b c", "d"] 269 | assert bean.parse_args("a 'b\"' c") == ["a", "b\"", "c"] 270 | assert bean.parse_args("a 'b' c d") == ["a", "b", "c", "d"] 271 | assert bean.parse_args("a ”b“ c d") == ["a", "b", "c", "d"] 272 | assert bean.parse_args("a “b ” c d") == ["a", "b ", "c", "d"] 273 | 274 | with pytest.raises(ValueError, match=bean.ArgsError.args[0]): 275 | bean.parse_args("a 'b") 276 | 277 | with pytest.raises(ValueError, match=bean.ArgsError.args[0]): 278 | bean.parse_args("a 'b c") 279 | 280 | with pytest.raises(ValueError, match=bean.ArgsError.args[0]): 281 | bean.parse_args("a “b c'") 282 | 283 | 284 | @pytest.fixture 285 | def copied_bean(tmp_path): 286 | new_bean = tmp_path / "example.bean" 287 | shutil.copyfile("testdata/example.bean", new_bean) 288 | yield new_bean 289 | Path(new_bean).unlink() 290 | 291 | 292 | def test_manager_reload(mock_config, copied_bean): 293 | manager = bean.BeanManager(copied_bean) 294 | account_amount = len(manager.accounts) 295 | entry_amount = len(manager.entries) 296 | assert len(manager.accounts) == 63 297 | assert len(manager.entries) == 2037 298 | 299 | # Append a "close" entry 300 | with open(copied_bean, "a") as f: 301 | f.write(f"{today} close Assets:US:BofA:Checking\n") 302 | 303 | # The account amount should reloaded 304 | assert len(manager.accounts) == account_amount - 1 305 | assert len(manager.entries) == entry_amount + 1 306 | 307 | 308 | def test_manager_commmit(mock_config, copied_bean): 309 | manager = bean.BeanManager(copied_bean) 310 | assert len(manager.entries) == 2037 311 | txs = f""" 312 | {today} * "Test Payee" "Test Narration" 313 | Liabilities:US:Chase:Slate -12.30 USD 314 | Expenses:Food:Restaurant 12.30 USD 315 | """ 316 | manager.commit_trx(txs) 317 | assert len(manager.entries) == 2038 318 | assert_txs_equal(manager.entries[-1], txs) 319 | -------------------------------------------------------------------------------- /frontend/src/ui.ts: -------------------------------------------------------------------------------- 1 | // UI组件模块 2 | import type { Message, ElementConfig, MessageStatus } from './types.ts'; 3 | import { 4 | messageStorage, messageHistory, submitTransactionButton, transactionText, cloneTransactionButton, 5 | messageFavorites, transactionDialog, errorDialog, slidingElements, 6 | amountDisplay, 7 | modifyAmountButton, 8 | numpadContainer, 9 | submitWithAmountButton, 10 | } from './storage.js'; 11 | import { toggleFavoriteStatus, deleteMessage } from './api.js'; 12 | import { buildIconDom, faTrash, faCheck, faStar } from './icons.js'; 13 | 14 | // 样式配置 15 | const STYLES = { 16 | messageContainer: (status: MessageStatus) => [ 17 | 'flex', 'container', 'max-w-4xl', 'justify-between', 'items-center', 'p-2', 18 | 'bg-white', 'rounded-lg', 'shadow-sm', 'relative', 'overflow-hidden', 'message', 19 | status === 'submitted' ? 'bg-green-200' : 'bg-gray-200' 20 | ], 21 | textDiv: ['justify-stretch', 'font-medium', 'text-gray-800'], 22 | rightContainer: ['flex', 'items-center', 'right-container'], 23 | collectIcon: (isFavorite: boolean) => [ 24 | 'justify-end', 'p-2', 'text-gray-400', 25 | isFavorite ? 'text-yellow-500' : 'hover:text-yellow-500', 26 | 'hover:scale-120', 'transition', 'duration-20', 27 | 'ele-collect' 28 | ], 29 | submittedIcon: ['justify-end', 'p-2', 'text-green-500', 'font-bold', 'ele-check'], 30 | deleteButton: ['absolute', 'right-0', 'top-0', 'bottom-0', 'bg-red-500', 'text-white', 'flex', 'items-center', 'justify-center', 31 | 'w-10', 'px-4', 'transform', 'translate-x-full', 'transition-transform', 'duration-300', 'ease-out', 'ele-delete'] 32 | }; 33 | 34 | // 通用元素构建工具函数 35 | export function createElement( 36 | tag: string, 37 | { classes = [], attrs = {}, events = {} }: ElementConfig = {} 38 | ): T { 39 | const el = document.createElement(tag) as T; 40 | el.classList.add(...classes.filter(Boolean)); 41 | Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value)); 42 | Object.entries(events).forEach(([type, handler]) => el.addEventListener(type, handler)); 43 | return el; 44 | } 45 | 46 | // 构建已提交标记元素 47 | export function buildSubmittedElement(): HTMLElement { 48 | const element = createElement('div', { 49 | classes: STYLES.submittedIcon, 50 | attrs: { 'aria-label': 'Submitted' } 51 | }); 52 | element.appendChild(buildIconDom(faCheck)); 53 | return element; 54 | } 55 | 56 | // 构建收藏按钮元素 57 | export function buildCollectElement(msg: Message, onClick: EventListener): HTMLElement { 58 | const element = createElement('div', { 59 | classes: STYLES.collectIcon(msg.favorite), 60 | events: { click: (e) => { 61 | e.stopPropagation(); 62 | onClick(e); 63 | }} 64 | }); 65 | element.appendChild(buildIconDom(faStar)); 66 | return element; 67 | } 68 | 69 | function createDeleteButton(messageDiv: HTMLElement): HTMLElement { 70 | const id = Number(messageDiv.dataset.msgId!); 71 | const button = createElement('div', { 72 | classes: STYLES.deleteButton, 73 | events: { click: async (e) => { 74 | e.stopPropagation(); 75 | try { 76 | // 添加左滑消失动画 77 | messageDiv.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; 78 | messageDiv.style.transform = 'translateX(-100%)'; 79 | messageDiv.style.opacity = '0'; 80 | 81 | // 等待动画完成后再删除元素 82 | setTimeout(async () => { 83 | await deleteMessage(id); 84 | document.querySelectorAll(`.message[data-msg-id="${id}"]`)?.forEach(el => el.remove()); 85 | }, 300); 86 | } catch (error) { 87 | console.error('Error deleting message:', error); 88 | await showErrorDialog(errorDialog, error.message || '删除消息失败。请查看控制台获取详细信息。'); 89 | } 90 | }} 91 | }); 92 | // 使用导入的 faTrash 图标创建元素 93 | button.appendChild(buildIconDom(faTrash)); 94 | return button; 95 | } 96 | 97 | function clearSlidingElements() { 98 | slidingElements.forEach(el => { 99 | el.style.transform = 'translateX(0)'; 100 | el.querySelector('.ele-delete').style.transform = 'translateX(100%)'; 101 | }); 102 | slidingElements.clear(); 103 | } 104 | 105 | export function buildMessageElement(msg: Message): HTMLElement { 106 | const { id, message: msgText, status: msgStatus } = msg; 107 | 108 | // 构建消息主体 109 | const messageDiv = createElement('div', { 110 | classes: STYLES.messageContainer(msgStatus), 111 | attrs: { 'data-msg-id': id.toString() }, 112 | events: { click: (e) => { 113 | showTransactionDialog(id); 114 | // 将所有划过去的元素复位 115 | clearSlidingElements(); 116 | e.stopPropagation(); 117 | }} 118 | }); 119 | 120 | // 文本内容区域 121 | const textDiv = createElement('div', { 122 | classes: STYLES.textDiv 123 | }); 124 | textDiv.textContent = msgText; 125 | 126 | // 右侧操作区域 127 | const rightDiv = createElement('div', { 128 | classes: STYLES.rightContainer 129 | }); 130 | 131 | // 收藏按钮 132 | const collectDiv = buildCollectElement(msg, () => handleToggleFavorite(id.toString())); 133 | 134 | // 条件元素 135 | if (msgStatus === 'submitted') { 136 | rightDiv.appendChild(buildSubmittedElement()); 137 | } 138 | rightDiv.appendChild(collectDiv); 139 | messageDiv.append(textDiv, rightDiv); 140 | 141 | // 添加删除按钮 142 | const deleteButton = createDeleteButton(messageDiv); 143 | messageDiv.appendChild(deleteButton); 144 | 145 | // 添加左滑手势 146 | let startX = 0; 147 | let currentX = 0; 148 | let isDragging = false; 149 | let draggingStartTime = 0; 150 | const dragThreshold = 100; 151 | 152 | const handleTouchStart = (e: TouchEvent) => { 153 | startX = e.touches[0].clientX; 154 | isDragging = true; 155 | draggingStartTime = Date.now(); 156 | }; 157 | 158 | const handleTouchMove = (e: TouchEvent) => { 159 | if (!isDragging) return; 160 | if (Date.now() - draggingStartTime < dragThreshold) return; // 忽略短时间内的滑动,防止误触 161 | currentX = e.touches[0].clientX; 162 | const diffX = startX - currentX; 163 | 164 | // 只允许左滑 165 | if (diffX > 0) { 166 | // 限制最大滑动距离为删除按钮宽度 167 | const translateX = Math.min(diffX, 80); 168 | messageDiv.style.transform = `translateX(-${translateX}px)`; 169 | deleteButton.style.transform = `translateX(calc(100% - ${translateX}px))`; 170 | } 171 | }; 172 | 173 | const handleTouchEnd = () => { 174 | if (!isDragging) return; 175 | if (Date.now() - draggingStartTime < dragThreshold) return; // 忽略短时间内的滑动,防止误触 176 | 177 | isDragging = false; 178 | 179 | const diffX = startX - currentX; 180 | 181 | // 如果滑动距离超过阈值,显示删除按钮 182 | if (diffX > 40) { 183 | clearSlidingElements(); // 将其他元素复位 184 | messageDiv.style.transform = 'translateX(-80px)'; 185 | deleteButton.style.transform = 'translateX(calc(100% - 80px))'; 186 | slidingElements.add(messageDiv); 187 | } else { 188 | // 否则恢复原位 189 | messageDiv.style.transform = 'translateX(0)'; 190 | deleteButton.style.transform = 'translateX(100%)'; 191 | slidingElements.delete(messageDiv); 192 | } 193 | }; 194 | 195 | // 点击其他区域时恢复原位 196 | document.addEventListener('click', (e) => { 197 | if (!messageDiv.contains(e.target as Node)) { 198 | messageDiv.style.transform = 'translateX(0)'; 199 | deleteButton.style.transform = 'translateX(100%)'; 200 | slidingElements.delete(messageDiv); 201 | } 202 | }); 203 | 204 | messageDiv.addEventListener('touchstart', handleTouchStart); 205 | messageDiv.addEventListener('touchmove', handleTouchMove); 206 | messageDiv.addEventListener('touchend', handleTouchEnd); 207 | 208 | return messageDiv; 209 | } 210 | 211 | // 添加消息到列表 212 | export async function appendMessage(listElement: HTMLElement, msg: Message): Promise { 213 | const { id } = msg; 214 | messageStorage.set(id, msg); 215 | 216 | const messageDiv = buildMessageElement(msg); 217 | 218 | listElement.firstElementChild.appendChild(messageDiv); 219 | return messageDiv; 220 | } 221 | 222 | // 显示错误提示 223 | export async function showErrorDialog(errorDialog: HTMLElement, message: string): Promise { 224 | errorDialog.textContent = message; 225 | errorDialog.classList.remove('hidden'); 226 | await new Promise(resolve => setTimeout(resolve, 3000)); 227 | errorDialog.classList.add('hidden'); 228 | } 229 | 230 | // 切换标签页 231 | export function switchTab(tab: string): void { 232 | // 切换选项卡 233 | document.querySelectorAll('[data-tab]').forEach(el => { 234 | el.classList.toggle('hidden', el.dataset.tab !== tab); 235 | }); 236 | 237 | // 更新按钮状态 238 | document.querySelectorAll('[data-tab-button]').forEach(btn => { 239 | btn.classList.toggle('bg-blue-500', btn.dataset.tabButton === tab); 240 | btn.classList.toggle('text-white', btn.dataset.tabButton === tab); 241 | btn.classList.toggle('bg-gray-100', btn.dataset.tabButton !== tab); 242 | }); 243 | } 244 | 245 | // 标记按钮操作成功 246 | export async function markButtonSuccess(button: HTMLButtonElement): Promise { 247 | const originalButtonText = button.textContent ?? ''; 248 | 249 | button.disabled = true; 250 | button.classList.remove('bg-blue-500', 'hover:bg-blue-700'); 251 | button.classList.add('bg-green-500'); 252 | button.textContent = 'Done!'; 253 | await new Promise(resolve => setTimeout(resolve, 1000)); 254 | 255 | button.disabled = false; 256 | button.classList.remove('bg-green-500'); 257 | button.classList.add('bg-blue-500', 'hover:bg-blue-700'); 258 | button.textContent = originalButtonText; 259 | } 260 | 261 | // 显示交易对话框 262 | export function showTransactionDialog(msgId: number): void { 263 | const message = messageStorage.get(msgId); 264 | if (!message) return; 265 | 266 | const { transaction_text: txText, status: msgStatus } = message; 267 | 268 | transactionText.textContent = txText; 269 | transactionText.dataset.msgId = msgId.toString(); 270 | 271 | const isSubmitted = msgStatus === 'submitted'; 272 | submitTransactionButton.classList.toggle('hidden', isSubmitted); 273 | cloneTransactionButton.classList.toggle('hidden', !isSubmitted); 274 | modifyAmountButton.classList.toggle('hidden', false); 275 | submitWithAmountButton.classList.toggle('hidden', true); 276 | numpadContainer.classList.add('hidden'); 277 | transactionDialog.classList.remove('hidden'); 278 | 279 | // 重置数字键盘显示 280 | resetAmountDisplay(); 281 | 282 | [messageHistory, messageFavorites].forEach((list: HTMLElement) => { 283 | list.scrollTop = list.scrollHeight; 284 | }); 285 | } 286 | 287 | // 隐藏交易对话框 288 | export function hideTransactionDialog(): void { 289 | transactionDialog.classList.add('hidden'); 290 | numpadContainer.classList.add('hidden'); 291 | modifyAmountButton.classList.remove('hidden'); 292 | submitWithAmountButton.classList.add('hidden'); 293 | } 294 | 295 | // 提取交易金额 296 | export function extractAmount(text: string): string | null { 297 | // 匹配交易文本中的金额,假设金额是第一个数字 298 | const match = text.match(/^\s*(\d+(\.\d+)?)\s/); 299 | return match ? match[1] : null; 300 | } 301 | 302 | // 重置金额显示 303 | export function resetAmountDisplay(): void { 304 | const msgId = transactionText.dataset.msgId; 305 | if (!msgId) return; 306 | 307 | const message = messageStorage.get(Number(msgId)); 308 | if (!message) return; 309 | 310 | const amount = extractAmount(message.message); 311 | amountDisplay.textContent = amount || ''; 312 | amountDisplay.dataset.originalAmount = amount || ''; 313 | } 314 | 315 | // 显示数字键盘 316 | export function showNumpad(): void { 317 | numpadContainer.classList.remove('hidden'); 318 | modifyAmountButton.classList.add('hidden'); 319 | submitWithAmountButton.classList.remove('hidden'); 320 | 321 | // 确保金额显示已初始化 322 | if (!amountDisplay.textContent) { 323 | resetAmountDisplay(); 324 | } 325 | } 326 | 327 | // 隐藏数字键盘 328 | export function hideNumpad(): void { 329 | numpadContainer.classList.add('hidden'); 330 | modifyAmountButton.classList.remove('hidden'); 331 | submitWithAmountButton.classList.add('hidden'); 332 | } 333 | 334 | // 处理数字键盘输入 335 | export function handleNumpadInput(value: string): void { 336 | // 如果是第一次点击,清空显示 337 | if (amountDisplay.textContent === amountDisplay.dataset.originalAmount) { 338 | amountDisplay.textContent = ''; 339 | } 340 | 341 | // 处理小数点 342 | if (value === '.' && amountDisplay.textContent.includes('.')) { 343 | return; // 已经有小数点了,忽略 344 | } 345 | 346 | // 添加数字或小数点 347 | amountDisplay.textContent += value; 348 | } 349 | 350 | // 清除金额显示 351 | export function clearAmountDisplay(): void { 352 | amountDisplay.textContent = ''; 353 | } 354 | 355 | // 切换收藏状态 356 | export async function handleToggleFavorite(msgId: string): Promise { 357 | try { 358 | const message = messageStorage.get(Number(msgId)); 359 | if (!message) return; 360 | 361 | const targetFavorite = !message.favorite; 362 | await toggleFavoriteStatus(Number(msgId), targetFavorite); 363 | message.favorite = targetFavorite; 364 | 365 | const messageElements = document.querySelectorAll(`[data-msg-id="${msgId}"] div.ele-collect`); 366 | messageElements.forEach(messageElement => { 367 | messageElement.classList.toggle("text-yellow-500", targetFavorite); 368 | messageElement.classList.toggle("hover:text-yellow-500", !targetFavorite); 369 | // 清空现有内容并添加图标 370 | // messageElement.innerHTML = ''; 371 | // messageElement.appendChild(buildIconDom(faStar)); 372 | }); 373 | } catch (error) { 374 | console.error('Error toggling favorite:', error); 375 | await showErrorDialog(errorDialog, error.message || 'Toggle favorite failed. Please check console for details.'); 376 | } 377 | } -------------------------------------------------------------------------------- /frontend/dist/workbox-e3490c72.js: -------------------------------------------------------------------------------- 1 | define(["exports"],function(t){"use strict";try{self["workbox:core:7.2.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.2.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class i{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class r extends i{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class o{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let o=r&&r.handler;const c=t.method;if(!o&&this.i.has(c)&&(o=this.i.get(c)),!o)return;let a;try{a=o.handle({url:s,request:t,event:e,params:i})}catch(t){a=Promise.reject(t)}const h=r&&r.catchHandler;return a instanceof Promise&&(this.o||h)&&(a=a.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:i})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),a}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const i=this.t.get(s.method)||[];for(const r of i){let i;const o=r.match({url:t,sameOrigin:e,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&0===i.length||o.constructor===Object&&0===Object.keys(o).length||"boolean"==typeof o)&&(i=void 0),{route:r,params:i}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let c;const a=()=>(c||(c=new o,c.addFetchListener(),c.addCacheListener()),c);function h(t,e,n){let o;if("string"==typeof t){const s=new URL(t,location.href);o=new i(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)o=new r(t,e,n);else if("function"==typeof t)o=new i(t,e,n);else{if(!(t instanceof i))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});o=t}return a().registerRoute(o),o}const u={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},l=t=>[u.prefix,t,u.suffix].filter(t=>t&&t.length>0).join("-"),f=t=>t||l(u.precache),w=t=>t||l(u.runtime);function d(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:7.2.0"]&&_()}catch(t){}function p(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const i=new URL(n,location.href),r=new URL(n,location.href);return i.searchParams.set("__WB_REVISION__",e),{cacheKey:i.href,url:r.href}}class y{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class g{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.h.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.h=t}}let R;async function m(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const i=t.clone(),r={headers:new Headers(i.headers),status:i.status,statusText:i.statusText},o=e?e(r):r,c=function(){if(void 0===R){const t=new Response("");if("body"in t)try{new Response(t.body),R=!0}catch(t){R=!1}R=!1}return R}()?i.body:await i.blob();return new Response(c,o)}function v(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class q{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const U=new Set;try{self["workbox:strategies:7.2.0"]&&_()}catch(t){}function L(t){return"string"==typeof t?new Request(t):t}class b{constructor(t,e){this.u={},Object.assign(this,e),this.event=e.event,this.l=t,this.p=new q,this.R=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.p.promise)}async fetch(t){const{event:e}=this;let n=L(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const i=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const r=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.l.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:r,response:t});return t}catch(t){throw i&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:i.clone(),request:r.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=L(t);let s;const{cacheName:n,matchOptions:i}=this.l,r=await this.getCacheKey(e,"read"),o=Object.assign(Object.assign({},i),{cacheName:n});s=await caches.match(r,o);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:i,cachedResponse:s,request:r,event:this.event})||void 0;return s}async cachePut(t,e){const n=L(t);var i;await(i=0,new Promise(t=>setTimeout(t,i)));const r=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(o=r.url,new URL(String(o),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var o;const c=await this.q(e);if(!c)return!1;const{cacheName:a,matchOptions:h}=this.l,u=await self.caches.open(a),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const i=v(e.url,s);if(e.url===i)return t.match(e,n);const r=Object.assign(Object.assign({},n),{ignoreSearch:!0}),o=await t.keys(e,r);for(const e of o)if(i===v(e.url,s))return t.match(e,n)}(u,r.clone(),["__WB_REVISION__"],h):null;try{await u.put(r,l?c.clone():c)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of U)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:a,oldResponse:f,newResponse:c.clone(),request:r,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.u[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=L(await t({mode:e,request:n,event:this.event,params:this.params}));this.u[s]=n}return this.u[s]}hasCallback(t){for(const e of this.l.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.l.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const i=Object.assign(Object.assign({},n),{state:s});return e[t](i)};yield n}}waitUntil(t){return this.R.push(t),t}async doneWaiting(){let t;for(;t=this.R.shift();)await t}destroy(){this.p.resolve(null)}async q(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class C{constructor(t={}){this.cacheName=w(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,i=new b(this,{event:e,request:s,params:n}),r=this.U(i,s,e);return[r,this.L(r,i,s,e)]}async U(t,e,n){let i;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(i=await this._(e,t),!i||"error"===i.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const r of t.iterateCallbacks("handlerDidError"))if(i=await r({error:s,event:n,request:e}),i)break;if(!i)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))i=await s({event:n,request:e,response:i});return i}async L(t,e,s,n){let i,r;try{i=await t}catch(r){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:i}),await e.doneWaiting()}catch(t){t instanceof Error&&(r=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:i,error:r}),e.destroy(),r)throw r}}class E extends C{constructor(t={}){t.cacheName=f(t.cacheName),super(t),this.C=!1!==t.fallbackToNetwork,this.plugins.push(E.copyRedirectedCacheableResponsesPlugin)}async _(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.O(t,e):await this.N(t,e))}async N(t,e){let n;const i=e.params||{};if(!this.C)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=i.integrity,r=t.integrity,o=!r||r===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?r||s:void 0})),s&&o&&"no-cors"!==t.mode&&(this.k(),await e.cachePut(t,n.clone()))}return n}async O(t,e){this.k();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}k(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==E.copyRedirectedCacheableResponsesPlugin&&(n===E.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(E.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}E.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},E.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await m(t):t};class O{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.K=new Map,this.P=new Map,this.T=new Map,this.l=new E({cacheName:f(t),plugins:[...e,new g({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.l}precache(t){this.addToCacheList(t),this.W||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.W=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:i}=p(n),r="string"!=typeof n&&n.revision?"reload":"default";if(this.K.has(i)&&this.K.get(i)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.K.get(i),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.T.has(t)&&this.T.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:i});this.T.set(t,n.integrity)}if(this.K.set(i,t),this.P.set(i,r),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return d(t,async()=>{const e=new y;this.strategy.plugins.push(e);for(const[e,s]of this.K){const n=this.T.get(s),i=this.P.get(e),r=new Request(e,{integrity:n,cache:i,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:r,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}})}activate(t){return d(t,async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.K.values()),n=[];for(const i of e)s.has(i.url)||(await t.delete(i),n.push(i.url));return{deletedURLs:n}})}getURLsToCacheKeys(){return this.K}getCachedURLs(){return[...this.K.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.K.get(e.href)}getIntegrityForCacheKey(t){return this.T.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}let x;const N=()=>(x||(x=new O),x);class k extends i{constructor(t,e){super(({request:s})=>{const n=t.getURLsToCacheKeys();for(const i of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:i}={}){const r=new URL(t,location.href);r.hash="",yield r.href;const o=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some(t=>t.test(s))&&t.searchParams.delete(s);return t}(r,e);if(yield o.href,s&&o.pathname.endsWith("/")){const t=new URL(o.href);t.pathname+=s,yield t.href}if(n){const t=new URL(o.href);t.pathname+=".html",yield t.href}if(i){const t=i({url:r});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(i);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}},t.strategy)}}t.NavigationRoute=class extends i{constructor(t,{allowlist:e=[/./],denylist:s=[]}={}){super(t=>this.j(t),t),this.M=e,this.S=s}j({url:t,request:e}){if(e&&"navigate"!==e.mode)return!1;const s=t.pathname+t.search;for(const t of this.S)if(t.test(s))return!1;return!!this.M.some(t=>t.test(s))}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",t=>{const e=f();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter(s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t);return await Promise.all(s.map(t=>self.caches.delete(t))),s})(e).then(t=>{}))})},t.createHandlerBoundToURL=function(t){return N().createHandlerBoundToURL(t)},t.precacheAndRoute=function(t,e){!function(t){N().precache(t)}(t),function(t){const e=N();h(new k(e,t))}(e)},t.registerRoute=h}); 2 | --------------------------------------------------------------------------------