diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e071af6..5880127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,93 +1,23 @@ name: CI on: - push: - branches: [ "master" ] - tags: - - 'v*.*.*' pull_request: - branches: [ "master" ] jobs: - test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.12', '3.11'] steps: - - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: '1.21' - cache: true - - - name: Test - run: | - export TEST_DEBUG=1 - export TEST_EXTRA_TAGS=" " - bash ./scripts/run_tests.sh - - - name: Coverage Badge - Generate - if: github.event_name != 'pull_request' - uses: tj-actions/coverage-badge-go@v2 - with: - filename: coverage-percent.out - - - name: Verify Changed files - uses: tj-actions/verify-changed-files@v12 - id: verify-changed-files + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - files: README.md - - - name: Commit changes - if: steps.verify-changed-files.outputs.files_changed == 'true' + python-version: ${{ matrix.python-version }} + - name: Install package run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add README.md - git commit -m "chore: Update README.md" + python -m pip install --upgrade pip + python -m pip install '.[dev]' + - run: tox - - name: Push changes - if: steps.verify-changed-files.outputs.files_changed == 'true' - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.head_ref }} - - services: - mail_server: - image: ghcr.io/deltachat/mail-server-tester:release - ports: - - 3025:25 - - 3143:143 - - 3465:465 - - 3993:993 - - - release: - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - run: git fetch --force --tags - - id: check-tag - run: | - if [[ "${{ github.event.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo ::set-output name=match::true - fi - - uses: actions/setup-go@v3 - if: ${{ github.event_name == 'push' && steps.check-tag.outputs.match == 'true' }} - with: - go-version: '1.21' - cache: true - - run: sudo apt install gcc-multilib - - uses: goreleaser/goreleaser-action@v4 - if: ${{ github.event_name == 'push' && steps.check-tag.outputs.match == 'true' }} - with: - distribution: goreleaser - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 33b9450..c85850c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ coverage-percent.out coverage.out group-editor-bot .idea/* +src/*.egg-info/* +.venv/* +build/* +/accounts/ +**/__pycache__/* diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f3ff95e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include src/group_editor_bot/*.xdc diff --git a/go.mod b/go.mod deleted file mode 100644 index 86d9391..0000000 --- a/go.mod +++ /dev/null @@ -1,31 +0,0 @@ -module github.com/deltachat-bot/group-editor-bot - -go 1.21 - -toolchain go1.21.0 - -require ( - github.com/cavaliergopher/grab/v3 v3.0.1 - github.com/deltachat-bot/deltabot-cli-go v0.6.1-0.20250108161819-a337c44b9703 - github.com/deltachat/deltachat-rpc-client-go v1.134.0 - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/spf13/cobra v1.8.0 - github.com/stretchr/testify v1.8.2 -) - -require ( - github.com/creachadair/jrpc2 v1.1.2 // indirect - github.com/creachadair/mds v0.8.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mdp/qrterminal/v3 v3.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.26.0 // indirect - golang.org/x/sync v0.6.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/term v0.16.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - rsc.io/qr v0.2.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 6dbbb67..0000000 --- a/go.sum +++ /dev/null @@ -1,57 +0,0 @@ -github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4= -github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creachadair/jrpc2 v1.1.2 h1:UOYMipEFYlwd5qmcvs9GZBurn3oXt1UDIX5JLjWWFzo= -github.com/creachadair/jrpc2 v1.1.2/go.mod h1:JcCe2Eny3lIvVwZLm92WXyU+tNUgTBWFCLMsfNkjEGk= -github.com/creachadair/mds v0.8.2 h1:+Jvq8XBrREerXI/QZpNAeiLjIBuVMOl8p3v+mKgSexY= -github.com/creachadair/mds v0.8.2/go.mod h1:4vrFYUzTXMJpMBU+OA292I6IUxKWCCfZkgXg+/kBZMo= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deltachat-bot/deltabot-cli-go v0.6.1-0.20250108161819-a337c44b9703 h1:JTEAZagMi09rl5hC6v/OCjulQhK7joXIaOaPyzthvAI= -github.com/deltachat-bot/deltabot-cli-go v0.6.1-0.20250108161819-a337c44b9703/go.mod h1:Va1UY+jfELQZvB3mNVpW8ACdECDJbeWve7SynvtgQ5c= -github.com/deltachat/deltachat-rpc-client-go v1.134.0 h1:rpGa/kL417ufyxsivT/G751aZifh8bpiPTIsXMzdDAI= -github.com/deltachat/deltachat-rpc-client-go v1.134.0/go.mod h1:Ctd0M0o87y2B0QSOn8QN6IMDWjHD7XzDKsjNMYwP208= -github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= -github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= -rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/group-editor-bot.go b/group-editor-bot.go deleted file mode 100644 index c45c7df..0000000 --- a/group-editor-bot.go +++ /dev/null @@ -1,264 +0,0 @@ -package main - -import ( - "math/rand" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/cavaliergopher/grab/v3" - "github.com/deltachat-bot/deltabot-cli-go/botcli" - "github.com/deltachat/deltachat-rpc-client-go/deltachat" - "github.com/deltachat/deltachat-rpc-client-go/deltachat/option" - qrcode "github.com/skip2/go-qrcode" - "github.com/spf13/cobra" -) - -var cli = botcli.New("group-editor-bot") - -func onBotInit(cli *botcli.BotCli, bot *deltachat.Bot, cmd *cobra.Command, args []string) { - bot.OnNewMsg(onNewMsg) - - accounts, err := bot.Rpc.GetAllAccountIds() - if err != nil { - cli.Logger.Error(err) - } - for _, accId := range accounts { - name, err := bot.Rpc.GetConfig(accId, "displayname") - if err != nil { - cli.Logger.Error(err) - } - if name.UnwrapOr("") == "" { - err = bot.Rpc.SetConfig(accId, "displayname", option.Some("Group Editor Bot")) - if err != nil { - cli.Logger.Error(err) - } - status := "I am a bot that helps managing editors in groups, send me /help for more info" - err = bot.Rpc.SetConfig(accId, "selfstatus", option.Some(status)) - if err != nil { - cli.Logger.Error(err) - } - err = bot.Rpc.SetConfig(accId, "delete_server_after", option.Some("1")) - if err != nil { - cli.Logger.Error(err) - } - } - } -} - -func onNewMsg(bot *deltachat.Bot, accId deltachat.AccountId, msgId deltachat.MsgId) { - logger := cli.GetLogger(accId).With("msg", msgId) - selfAddr, err := bot.Rpc.GetConfig(accId, "addr") - msg, err := bot.Rpc.GetMessage(accId, msgId) - if err != nil { - logger.Error(err) - return - } - - if msg.SystemMessageType == deltachat.SysmsgMemberAddedToGroup { - resendPads(bot.Rpc, accId, msg.ChatId) - } - - if msg.SystemMessageType == deltachat.SysmsgMemberRemovedFromGroup { - if strings.Contains(msg.Text, "Member Me ("+*selfAddr.Value+") removed by ") { - bot.Rpc.DeleteChat(accId, msg.ChatId) - } - } - - if !msg.IsBot && !msg.IsInfo && msg.FromId > deltachat.ContactLastSpecial { - chat, err := bot.Rpc.GetBasicChatInfo(accId, msg.ChatId) - if err != nil { - logger.Error(err) - return - } - if chat.ChatType == deltachat.ChatSingle || strings.HasPrefix(msg.Text, "/") { - err = bot.Rpc.MarkseenMsgs(accId, []deltachat.MsgId{msg.Id}) - if err != nil { - logger.Error(err) - } - } - - args := strings.Split(msg.Text, " ") - switch args[0] { - case "/invite": - if chat.ChatType == deltachat.ChatGroup { - sendInviteQr(bot.Rpc, accId, msg.ChatId) - } else { - text := "The /invite command can only be used in groups, send /help for more info" - _, err := bot.Rpc.SendMsg(accId, msg.ChatId, deltachat.MsgData{Text: text}) - if err != nil { - logger.Error(err) - } - } - case "/pin": - sendMessage(bot.Rpc, accId, msg.ChatId, msg.Text) - return - case "/editor": - sendPad(bot.Rpc, accId, msg.ChatId, msg.Text) - return - case "/help": - sendHelp(bot.Rpc, accId, msg.ChatId) - default: - if chat.ChatType == deltachat.ChatSingle { - sendHelp(bot.Rpc, accId, msg.ChatId) - } - } - } - - if msg.Sender.Address != selfAddr.Unwrap() { - err = bot.Rpc.DeleteMessages(accId, []deltachat.MsgId{msg.Id}) - if err != nil { - logger.Error(err) - } else { - println("Deleted message " + strconv.FormatUint(uint64(msg.Id), 10)) - } - } -} - -func sendMessage(rpc *deltachat.Rpc, accId deltachat.AccountId, chatId deltachat.ChatId, command string) { - var description string - if len(command) > 7 { - description = command[5:] // bot adds text after /pin as text for the pinned message - } else { - description = "" - } - msgID, err := rpc.SendMsg(accId, chatId, deltachat.MsgData{Text: description}) - if err != nil { - cli.GetLogger(accId).With("chat", chatId).Error(err) - } - cli.Logger.Info("Sent pinned message " + string(msgID)) -} - -func sendPad(rpc *deltachat.Rpc, accId deltachat.AccountId, chatId deltachat.ChatId, command string) { - HomeDir, err := os.UserHomeDir() - editor_path := filepath.Join(HomeDir, ".config", "group-editor-bot", "durian-realtime-editor-v4.0.4.xdc") - var description string - if len(command) > 7 { - description = command[8:] // bot adds text after /editor as description to the editor.xdc message - } else { - description = "" - } - msgID, err := rpc.SendMsg(accId, chatId, deltachat.MsgData{Text: description, File: editor_path}) - if err != nil { - cli.GetLogger(accId).With("chat", chatId).Error(err) - } - cli.Logger.Info("Sent editor message " + string(msgID)) -} - -func resendPads(rpc *deltachat.Rpc, accId deltachat.AccountId, chatId deltachat.ChatId) { - logger := cli.GetLogger(accId).With("chat", chatId) - var toResend []deltachat.MsgId - selfAddr, err := rpc.GetConfig(accId, "addr") - if err == nil { - msgIds, _ := rpc.GetMessageIds(accId, chatId, false, false) - var msgIdStrings []string - for i := range msgIds { - msgIdStrings = append(msgIdStrings, strconv.FormatUint(uint64(msgIds[i]), 10)) - } - // println("In this chat I know the messages: " + strings.Join(msgIdStrings, ",")) - for _, id := range msgIds { - msg, _ := rpc.GetMessage(accId, id) - senderaddress := msg.Sender.Address - // println(strconv.FormatUint(uint64(msg.Id), 10) + senderaddress + selfAddr.Unwrap()) - // delete MemberAdded System Messages instead of trying to resend them; it will fail - if msg.SystemMessageType == deltachat.SysmsgMemberAddedToGroup && msg.Sender.Address == selfAddr.Unwrap() { - text := msg.Text - err = rpc.DeleteMessages(accId, []deltachat.MsgId{msg.Id}) - if err != nil { - logger.Error(err) - } else { - println("Deleted message " + strconv.FormatUint(uint64(msg.Id), 10) + ": " + text) - } - continue - } - if senderaddress == selfAddr.Unwrap() { - toResend = append(toResend, id) - } - } - // We wait here 5 seconds because otherwise the newly added member - // might get the bot's messages before the member-added message - // which breaks for the newly added member ("unable to verify sender") - // This needs to be prevented on the chatmail core side but as of May 15 2025 - // this wait here makes it less likely the asynchronicity issue happens. - time.Sleep(5 * time.Second) - if toResend != nil { - err := rpc.ResendMessages(accId, toResend) - for err != nil { - var msgIdsStrings []string - for i := range toResend { - msgIdsStrings = append(msgIdsStrings, strconv.FormatUint(uint64(toResend[i]), 10)) - } - cli.Logger.Error("Resending messages " + strings.Join(msgIdsStrings, ",") + " failed with error: '" + err.Error() + "'. Retrying.") - r := rand.Intn(10) - time.Sleep(time.Duration(r) * time.Second) - err = rpc.ResendMessages(accId, toResend) - } - } - } -} - -func sendHelp(rpc *deltachat.Rpc, accId deltachat.AccountId, chatId deltachat.ChatId) { - text := "I am a bot that manages editors in groups.\n\n" - text += "To create a new shared editor for the group, you can write:\n\n" - text += "/editor Shopping List for Friday's Example Party\n\n" - text += "I will send an editor to the group, which anyone can edit; and if new members are added, they will see it, too." - msgId, err := rpc.SendMsg(accId, chatId, deltachat.MsgData{Text: text}) - if err != nil { - cli.GetLogger(accId).With("chat", chatId).Error(err) - } - time.Sleep(10 * time.Second) // sleep for 10 seconds, so the message has a chance to be sent - err = rpc.DeleteMessages(accId, []deltachat.MsgId{msgId}) - if err != nil { - cli.Logger.Error(err) - } -} - -func sendInviteQr(rpc *deltachat.Rpc, accId deltachat.AccountId, chatId deltachat.ChatId) { - logger := cli.GetLogger(accId).With("chat", chatId) - qrdata, _, err := rpc.GetChatSecurejoinQrCodeSvg(accId, option.Some(chatId)) - if err != nil { - logger.Error(err) - return - } - - dir, err := os.MkdirTemp("", "") - if err != nil { - logger.Error(err) - return - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "qr.png") - - err = qrcode.WriteFile(qrdata, qrcode.Medium, 256, path) - if err != nil { - logger.Error(err) - return - } - msgId, err := rpc.SendMsg(accId, chatId, deltachat.MsgData{Text: qrdata, File: path}) - if err != nil { - logger.Error(err) - } - time.Sleep(10 * time.Second) // sleep for 10 seconds, so the message has a chance to be sent - err = rpc.DeleteMessages(accId, []deltachat.MsgId{msgId}) - if err != nil { - cli.Logger.Error(err) - } -} - -func main() { - cli.OnBotInit(onBotInit) - - HomeDir, err := os.UserHomeDir() - DownloadDir := filepath.Join(HomeDir, ".config", "group-editor-bot") - resp, err := grab.Get(DownloadDir, "https://apps.testrun.org/durian-realtime-editor-v4.0.4.xdc") - if err != nil { - cli.Logger.Error(err) - } - cli.Logger.Info("Download saved to ", resp.Filename) - - if err := cli.Start(); err != nil { - cli.Logger.Error(err) - } -} diff --git a/group-editor-bot_test.go b/group-editor-bot_test.go deleted file mode 100644 index c37ff0c..0000000 --- a/group-editor-bot_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "testing" - - "github.com/deltachat-bot/deltabot-cli-go/botcli" - "github.com/deltachat/deltachat-rpc-client-go/deltachat" - "github.com/stretchr/testify/require" -) - -type TestCallback func(bot *deltachat.Bot, botAcc deltachat.AccountId, userRpc *deltachat.Rpc, userAcc deltachat.AccountId) - -var acfactory *deltachat.AcFactory - -func TestMain(m *testing.M) { - acfactory = &deltachat.AcFactory{} - acfactory.TearUp() - defer acfactory.TearDown() - m.Run() -} - -func withBotAndUser(callback TestCallback) { - acfactory.WithOnlineBot(func(bot *deltachat.Bot, botAcc deltachat.AccountId) { - acfactory.WithOnlineAccount(func(userRpc *deltachat.Rpc, userAcc deltachat.AccountId) { - cli := &botcli.BotCli{AppDir: acfactory.MkdirTemp()} - onBotInit(cli, bot, nil, nil) - go bot.Run() //nolint:errcheck - callback(bot, botAcc, userRpc, userAcc) - }) - }) -} - -func TestBot(t *testing.T) { - withBotAndUser(func(bot *deltachat.Bot, botAcc deltachat.AccountId, userRpc *deltachat.Rpc, userAcc deltachat.AccountId) { - chatWithBot := acfactory.CreateChat(userRpc, userAcc, bot.Rpc, botAcc) - _, err := userRpc.MiscSendTextMessage(userAcc, chatWithBot, "hi") - require.Nil(t, err) - msg := acfactory.NextMsg(userRpc, userAcc) - require.Contains(t, msg.Text, "I am a bot") - - groupWithBot, err := userRpc.CreateGroupChat(userAcc, "test group", false) - require.Nil(t, err) - require.Nil(t, userRpc.AddContactToChat(userAcc, groupWithBot, msg.FromId)) - _, err = userRpc.MiscSendTextMessage(userAcc, groupWithBot, "hi") - require.Nil(t, err) - _, err = userRpc.MiscSendTextMessage(userAcc, groupWithBot, "/invite") - require.Nil(t, err) - msg = acfactory.NextMsg(userRpc, userAcc) - require.NotEmpty(t, msg.File) - require.Contains(t, msg.Text, "https://i.delta.chat") - }) -} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2036b9b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "group-editor-bot" +version = "1.0.0" +authors = [{ name = "missytake", email = "missytake@systemli.org" }] +description = "Delta Chat bot that manages editors for a group, even when new people join." +readme = "file: README.md" +requires-python = ">=3.11" +dependencies = [ + "deltachat-rpc-client>=2.16.0", + "deltachat-rpc-server>=2.16.0", +] + +[project.urls] +Repository = "https://github.com/deltachat-bot/group-editor-bot/" +Issues = "https://github.com/deltachat-bot/group-editor-bot/issues" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ['src'] + +[project.scripts] +group-editor-bot = "group_editor_bot.hooks:main" + +[project.optional-dependencies] +dev = ["pytest", "tox", "ruff",] + +[tool.ruff] +lint.select = [ + "F", # Pyflakes + "I", # isort + + "PLC", # Pylint Convention + "PLE", # Pylint Error + "PLW", # Pylint Warning +] + +[tool.pytest.ini_options] +addopts = "-v -ra --strict-markers" +log_format = "%(asctime)s %(levelname)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" +log_level = "INFO" + +[tool.tox] +isolated_build = true +envlist = ["lint","py"] + +[tool.tox.env_run_base] +description = "Run tests under {base_python}" +commands = [["pytest", "-v", "-rsXx {posargs}"]] +deps = [ + "pytest", + "pdbpp", +] + +[tool.tox.env.lint] +skipdist = true +skip_install = true +deps = ["ruff"] +commands = [ + ["ruff", "format", "--quiet", "--diff", "src/", "tests/"], + ["ruff", "check", "src/", "tests/"] +] diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh deleted file mode 100755 index cd853ea..0000000 --- a/scripts/run_tests.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/env bash - -# build frontend -go generate ./... - -echo "Checking code with gofmt..." -OUTPUT=`gofmt -d src` -if [ -n "$OUTPUT" ] -then - echo "$OUTPUT" - exit 1 -fi - -echo "Checking code with golangci-lint..." -if ! command -v golangci-lint &> /dev/null -then - echo "golangci-lint not found, installing..." - # binary will be $(go env GOPATH)/bin/golangci-lint - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 -fi -if ! golangci-lint run -then - exit 1 -fi - -# Install test dependencies -if ! command -v deltachat-rpc-server &> /dev/null -then - echo "deltachat-rpc-server not found, installing..." - curl -L https://github.com/deltachat/deltachat-core-rust/releases/latest/download/deltachat-rpc-server-x86_64-linux --output deltachat-rpc-server - chmod +x deltachat-rpc-server - export PATH=`pwd`:"$PATH" -fi -if ! command -v courtney &> /dev/null -then - echo "courtney not found, installing..." - go install github.com/dave/courtney@master -fi - -# run the tests -courtney -v -t="./..." ${TEST_EXTRA_TAGS:--t="-parallel=1"} -go tool cover -func=coverage.out -o=coverage-percent.out diff --git a/src/group_editor_bot/__init__.py b/src/group_editor_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/group_editor_bot/durian-realtime-editor-v4.0.4.xdc b/src/group_editor_bot/durian-realtime-editor-v4.0.4.xdc new file mode 100644 index 0000000..2c5b7fc Binary files /dev/null and b/src/group_editor_bot/durian-realtime-editor-v4.0.4.xdc differ diff --git a/src/group_editor_bot/hooks.py b/src/group_editor_bot/hooks.py new file mode 100644 index 0000000..b9a0da7 --- /dev/null +++ b/src/group_editor_bot/hooks.py @@ -0,0 +1,104 @@ +import importlib.resources +import os + +from deltachat_rpc_client import Chat, EventType, events, run_bot_cli + +hooks = events.HookCollection() + +HELP_MSG = ( + "I am a bot that manages editors in groups.\n\n" + "To create a new shared editor for the group, you can write:\n\n" + "/editor Shopping List for Friday's Example Party\n\n" + "I will send an editor to the group, which anyone can edit; and if new members are added, they will see it, too." +) + + +@hooks.on(events.NewMessage) +def command(event): + snapshot = event.message_snapshot + account = snapshot.chat.account + + if not snapshot.text.startswith("/"): + """Not a command""" + elif snapshot.text.strip() == "/invite": + reply = snapshot.chat.send_text(snapshot.chat.get_qr_code()) + elif snapshot.text.strip() == "/help": + reply = snapshot.chat.send_text(HELP_MSG) + elif snapshot.text.startswith("/pin"): + snapshot.chat.send_message(text=snapshot.text[5:], file=snapshot.file) + elif snapshot.text.startswith("/editor"): + editor_path = str( + importlib.resources.files(__package__) / "durian-realtime-editor-v4.0.4.xdc" + ) + snapshot.chat.send_message(text=snapshot.text[8:], file=editor_path) + + if snapshot.sender != account.self_contact: + account.delete_messages([snapshot]) + print(f"Deleted message {snapshot.id}") + if "reply" in locals(): + reply.wait_until_delivered() + account.delete_messages([reply]) + + +@hooks.on(events.MemberListChanged) +def member_added_or_removed(event): + """If a member was added to the group chat, re-send own messages.""" + snapshot = event.message_snapshot + if os.getenv("DEBUG") == "true": + change = "added" if event.member_added else "removed" + print("member %s was %s" % (event.member, change)) + if event.member_added: + # If member added to group, resend pads + resend_messages(snapshot.chat) + else: + if not snapshot.chat.get_full_snapshot().self_in_group: + delete_data(snapshot.chat) + + +@hooks.on(events.RawEvent) +def catch_events(event): + """This is called on every raw event and can be used for any kind of event handling. + Unfortunately deltachat-rpc-client doesn't offer high-level events for MSG_DELIVERED or SECUREJOIN_INVITER_PROGRESS + yet, so this needs to be done with raw events. + + :param event: the event object + """ + if os.getenv("DEBUG") == "true": + print(event) + if event.kind == EventType.SECUREJOIN_INVITER_PROGRESS: + if event.progress == 1000: + resend_messages(event.account.get_chat_by_id(event.chat_id)) + if event.kind == EventType.IMAP_CONNECTED: + event.account.set_config("selfstatus", HELP_MSG) + event.account.set_config("delete_device_after", "3600") + print( + "The bot can be reached via this invite link: " + + event.account.get_qr_code() + ) + + +def resend_messages(chat: Chat): + """Resend all own messages (except info messages) in a Chat.""" + to_resend = [] + for msg in chat.get_messages(): + msg_snap = msg.get_snapshot() + if msg_snap.sender == chat.account.self_contact and not msg_snap.is_info: + to_resend.append(msg) + chat.resend_messages(to_resend) + + +def delete_data(chat: Chat): + """For a message, delete the chat and all contacts which were in it to clean up.""" + contacts = chat.get_contacts() + chat.delete() + for member in contacts: + member.delete() + + +def main(): + """This is the CLI entry point.""" + run_bot_cli(hooks) + + +if __name__ == "__main__": + main() diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..0160d08 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,41 @@ +import os + +import pytest + +from group_editor_bot.hooks import delete_data + + +def test_delete_data(acfactory): + if not os.getenv("CHATMAIL_DOMAIN"): + os.environ["CHATMAIL_DOMAIN"] = "nine.testrun.org" + bot, user = acfactory.get_online_accounts(2) # waiter lock + joincode = bot.get_qr_code() + chat = user.secure_join(joincode) + bot.wait_for_securejoin_inviter_success() + + chat.send_text("hi :)") + msg = bot.wait_for_incoming_msg() + + assert len(bot.get_chatlist()) == 3 + delete_data(msg.get_snapshot().chat) + assert bot.get_contacts() == [] + assert len(bot.get_chatlist()) == 2 + + +@pytest.mark.parametrize( + ["text", "file", "reply_text", "reply_file"], [["", "", "", ""]] +) +def test_commands(text, file, reply_text, reply_file): + pytest.skip("Not yet tested") + + +def test_member_added(acfactory): + pytest.skip("Not yet tested") + + +def test_bot_removed(acfactory): + pytest.skip("Not yet tested") + + +def tests_bot_adds_member(acfactory): + pytest.skip("Not yet tested")