├── .gitignore ├── internal ├── slave │ ├── client.go │ ├── limb_client.go │ ├── limb_service.go │ └── onebot_client.go ├── db │ └── database.go ├── common │ ├── struct.go │ ├── message_chan.go │ ├── configure.go │ ├── key_mutex.go │ ├── util.go │ └── protocol.go ├── filter │ ├── filter_chain.go │ ├── voice_filter.go │ ├── sticker_filter.go │ └── emoticon_filter.go ├── master │ ├── callback.go │ ├── master_service.go │ ├── telegraph.go │ ├── command.go │ └── processor.go ├── manager │ ├── pager.go │ ├── topic_manager.go │ ├── link_manager.go │ ├── chat_manager.go │ └── messenge_manager.go └── onebot │ └── protocol.go ├── .editorconfig ├── Dockerfile ├── configure.yaml.example ├── .github └── workflows │ └── docker.yml ├── LICENSE ├── main.go ├── go.mod ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | .DS_Store 4 | 5 | vendor/ 6 | 7 | configure.yaml 8 | master.db 9 | -------------------------------------------------------------------------------- /internal/slave/client.go: -------------------------------------------------------------------------------- 1 | package slave 2 | 3 | import ( 4 | "github.com/duo/octopus/internal/common" 5 | ) 6 | 7 | type Client interface { 8 | Vendor() string 9 | 10 | SendEvent(_ *common.OctopusEvent) (*common.OctopusEvent, error) 11 | 12 | Dispose() 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yaml,yml,sql}] 12 | indent_style = space 13 | 14 | [.gitlab-ci.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /internal/db/database.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | 6 | _ "modernc.org/sqlite" 7 | ) 8 | 9 | var DB *sql.DB 10 | 11 | func init() { 12 | var err error 13 | DB, err = sql.Open("sqlite", "master.db?cache=shared&mode=rwc&_journal_mode=WAL&_busy_timeout=10000") 14 | if err != nil { 15 | panic(err) 16 | } 17 | DB.SetMaxOpenConns(1) 18 | } 19 | -------------------------------------------------------------------------------- /internal/common/struct.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type Limb struct { 10 | Type string // Type: telegram, qq, wechat, etc 11 | UID string 12 | ChatID string 13 | } 14 | 15 | func (l Limb) String() string { 16 | return fmt.Sprintf("%s%s%s%s%s", l.Type, VENDOR_SEP, l.UID, VENDOR_SEP, l.ChatID) 17 | } 18 | 19 | func LimbFromString(str string) (*Limb, error) { 20 | parts := strings.Split(str, VENDOR_SEP) 21 | if len(parts) != 3 { 22 | return nil, errors.New("limb format invalid") 23 | } 24 | 25 | return &Limb{parts[0], parts[1], parts[2]}, nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/filter/filter_chain.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import "github.com/duo/octopus/internal/common" 4 | 5 | type EventFilter interface { 6 | Apply(event *common.OctopusEvent) *common.OctopusEvent 7 | } 8 | 9 | type EventFilterChain struct { 10 | Filters []EventFilter 11 | } 12 | 13 | func (c EventFilterChain) Apply(event *common.OctopusEvent) *common.OctopusEvent { 14 | for _, filter := range c.Filters { 15 | event = filter.Apply(event) 16 | } 17 | return event 18 | } 19 | 20 | func NewEventFilterChain(filters ...EventFilter) EventFilterChain { 21 | return EventFilterChain{append(([]EventFilter)(nil), filters...)} 22 | } 23 | -------------------------------------------------------------------------------- /internal/master/callback.go: -------------------------------------------------------------------------------- 1 | package master 2 | 3 | import ( 4 | "fmt" 5 | "hash/fnv" 6 | "strconv" 7 | ) 8 | 9 | // Telegram command callback 10 | type Callback struct { 11 | Category string 12 | Acction string 13 | Query string 14 | Page int 15 | Data string 16 | } 17 | 18 | var cbMap = map[string]Callback{} 19 | 20 | func putCallback(cb Callback) string { 21 | h := fnv.New64a() 22 | h.Write([]byte(fmt.Sprintf("%v", cb))) 23 | hash := strconv.FormatUint(h.Sum64(), 10) 24 | cbMap[hash] = cb 25 | return hash 26 | } 27 | 28 | func getCallback(hash string) (Callback, bool) { 29 | cb, ok := cbMap[hash] 30 | return cb, ok 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine AS builder 2 | 3 | RUN apk add --no-cache --update --quiet --no-progress build-base 4 | 5 | WORKDIR /build 6 | 7 | COPY ./ . 8 | 9 | RUN set -ex \ 10 | && cd /build \ 11 | && go build -o octopus 12 | 13 | FROM alpine:latest 14 | 15 | RUN apk add --no-cache --update --quiet --no-progress ffmpeg tzdata \ 16 | && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 17 | && echo "Asia/Shanghai" > /etc/timezone 18 | #&& apk del --quiet --no-progress tzdata 19 | 20 | COPY --from=builder /build/octopus /usr/bin/octopus 21 | RUN chmod +x /usr/bin/octopus 22 | 23 | WORKDIR /data 24 | 25 | ENTRYPOINT [ "/usr/bin/octopus" ] 26 | -------------------------------------------------------------------------------- /internal/manager/pager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import "math" 4 | 5 | type Pager struct { 6 | NumPages int 7 | HasPrev, HasNext bool 8 | PrevPage, NextPage int 9 | ItemsPerPage int 10 | CurrentPage int 11 | NumItems int 12 | } 13 | 14 | func CalcPager(currentPage, itemsPerPage, numItems int) Pager { 15 | p := Pager{} 16 | 17 | p.NumItems = numItems 18 | p.ItemsPerPage = itemsPerPage 19 | 20 | p.NumPages = int(math.Ceil(float64(p.NumItems) / float64(p.ItemsPerPage))) 21 | 22 | if currentPage <= 0 { 23 | p.CurrentPage = 1 24 | } else if currentPage > p.NumPages { 25 | p.CurrentPage = p.NumPages 26 | } else { 27 | p.CurrentPage = currentPage 28 | } 29 | 30 | p.HasPrev = p.CurrentPage > 1 31 | p.HasNext = p.CurrentPage < p.NumPages 32 | 33 | if p.HasPrev { 34 | p.PrevPage = p.CurrentPage - 1 35 | } 36 | if p.HasNext { 37 | p.NextPage = p.CurrentPage + 1 38 | } 39 | 40 | return p 41 | } 42 | -------------------------------------------------------------------------------- /configure.yaml.example: -------------------------------------------------------------------------------- 1 | master: 2 | api_url: http://10.0.0.10:8081 # Optional, 3 | local_mode: true # Optional, 4 | admin_id: # Required, Telegram user id (administrator) 5 | token: 1234567:xxxxxxxx # Required, Telegram bot token 6 | proxy: http://1.1.1.1:7890 # Optional, proxy for Telegram 7 | page_size: 10 # Optional, command list result pagination size 8 | archive: # Optional 9 | - vendor: wechat # qq, wechat, etc 10 | uid: wxid_xxxxxxx # client id 11 | chat_id: 123456789 # Telegram supergroup id (topic enabled) 12 | telegraph: # Optional 13 | enable: true # Convert some message to telegra.ph article (e.g. QQ forward message) 14 | proxy: http://1.1.1.1:7890 # Optional, proxy for telegra.ph 15 | tokens: 16 | - abcdefg # telegra.ph tokens 17 | 18 | service: 19 | addr: 0.0.0.0:11111 # Required, listen address 20 | secret: hello # Required, 21 | send_timeout: 3m # Optional 22 | 23 | log: 24 | level: info 25 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v3 15 | - 16 | name: Set up QEMU 17 | uses: docker/setup-qemu-action@v2 18 | - 19 | name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v2 21 | - 22 | name: Login to Docker Hub 23 | uses: docker/login-action@v2 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | - 28 | name: Build and push 29 | uses: docker/build-push-action@v3 30 | with: 31 | context: . 32 | push: true 33 | platforms: | 34 | linux/amd64 35 | linux/arm64 36 | tags: | 37 | ${{ secrets.DOCKERHUB_USERNAME }}/octopus:${{ github.ref_name }} 38 | ${{ secrets.DOCKERHUB_USERNAME }}/octopus:latest 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Duo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/duo/octopus/internal/common" 10 | "github.com/duo/octopus/internal/master" 11 | "github.com/duo/octopus/internal/slave" 12 | 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | func main() { 17 | config, err := common.LoadConfig("configure.yaml") 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | logLevel, err := log.ParseLevel(config.Log.Level) 23 | if err == nil { 24 | log.SetLevel(logLevel) 25 | } 26 | log.SetFormatter(&log.TextFormatter{TimestampFormat: "2006-01-02 15:04:05", FullTimestamp: true}) 27 | 28 | masterToSlave := common.NewMessageChan(1024) 29 | slaveToMaster := common.NewMessageChan(1024) 30 | 31 | master := master.NewMasterService(config, slaveToMaster.Out(), masterToSlave.In()) 32 | master.Start() 33 | slave := slave.NewLimbService(config, masterToSlave.Out(), slaveToMaster.In()) 34 | slave.Start() 35 | 36 | c := make(chan os.Signal, 1) 37 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 38 | <-c 39 | 40 | fmt.Printf("\n") 41 | 42 | slave.Stop() 43 | master.Stop() 44 | } 45 | -------------------------------------------------------------------------------- /internal/common/message_chan.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // unbounded channel 4 | type MessageChan struct { 5 | in chan<- *OctopusEvent 6 | out <-chan *OctopusEvent 7 | buffer []*OctopusEvent 8 | } 9 | 10 | func NewMessageChan(capacity int) *MessageChan { 11 | in := make(chan *OctopusEvent, capacity) 12 | out := make(chan *OctopusEvent, capacity) 13 | 14 | ch := &MessageChan{ 15 | in: in, 16 | out: out, 17 | buffer: make([]*OctopusEvent, 0, capacity), 18 | } 19 | 20 | go func() { 21 | defer close(out) 22 | 23 | loop: 24 | for { 25 | val, ok := <-in 26 | if !ok { 27 | break loop 28 | } 29 | 30 | select { 31 | case out <- val: 32 | continue 33 | default: 34 | } 35 | 36 | ch.buffer = append(ch.buffer, val) 37 | for len(ch.buffer) > 0 { 38 | select { 39 | case val, ok := <-in: 40 | if !ok { 41 | break loop 42 | } 43 | ch.buffer = append(ch.buffer, val) 44 | 45 | case out <- ch.buffer[0]: 46 | ch.buffer = ch.buffer[1:] 47 | if len(ch.buffer) == 0 { 48 | ch.buffer = make([]*OctopusEvent, 0, capacity) 49 | } 50 | } 51 | } 52 | } 53 | 54 | for len(ch.buffer) > 0 { 55 | out <- ch.buffer[0] 56 | ch.buffer = ch.buffer[1:] 57 | } 58 | }() 59 | 60 | return ch 61 | } 62 | 63 | func (ch *MessageChan) In() chan<- *OctopusEvent { 64 | return ch.in 65 | } 66 | 67 | func (ch *MessageChan) Out() <-chan *OctopusEvent { 68 | return ch.out 69 | } 70 | -------------------------------------------------------------------------------- /internal/common/configure.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | const ( 11 | defaultPageSize = 10 12 | defaultSendTimeout = 3 * time.Minute 13 | ) 14 | 15 | type ArchiveChat struct { 16 | Vendor string `yaml:"vendor"` 17 | UID string `yaml:"uid"` 18 | ChatID int64 `yaml:"chat_id"` 19 | } 20 | 21 | type Configure struct { 22 | Master struct { 23 | APIURL string `yaml:"api_url"` 24 | LocalMode bool `yaml:"local_mode"` 25 | AdminID int64 `yaml:"admin_id"` 26 | Token string `yaml:"token"` 27 | Proxy string `yaml:"proxy"` 28 | PageSize int `yaml:"page_size"` 29 | Archive []ArchiveChat `yaml:"archive"` 30 | 31 | Telegraph struct { 32 | Enable bool `ymal:"enable"` 33 | Proxy string `yaml:"proxy"` 34 | Tokens []string `yaml:"tokens"` 35 | } `yaml:"telegraph"` 36 | } `yaml:"master"` 37 | 38 | Service struct { 39 | Addr string `yaml:"addr"` 40 | Secret string `yaml:"secret"` 41 | SendTiemout time.Duration `yaml:"send_timeout"` 42 | } `yaml:"service"` 43 | 44 | Log struct { 45 | Level string `yaml:"level"` 46 | } `yaml:"log"` 47 | } 48 | 49 | func LoadConfig(path string) (*Configure, error) { 50 | file, err := os.ReadFile(path) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | config := &Configure{} 56 | config.Master.APIURL = "https://api.telegram.org" 57 | config.Master.PageSize = defaultPageSize 58 | config.Service.SendTiemout = defaultSendTimeout 59 | if err := yaml.Unmarshal(file, &config); err != nil { 60 | return nil, err 61 | } 62 | 63 | return config, nil 64 | } 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/duo/octopus 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/Benau/tgsconverter v0.0.0-20210809170556-99f4a4f6337f 7 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.28 8 | github.com/PuerkitoBio/goquery v1.9.2 9 | github.com/gabriel-vasile/mimetype v1.4.4 10 | github.com/gorilla/websocket v1.5.3 11 | github.com/mitchellh/mapstructure v1.5.0 12 | github.com/sirupsen/logrus v1.9.3 13 | github.com/tidwall/gjson v1.17.1 14 | github.com/youthlin/silk v0.0.4 15 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 16 | golang.org/x/net v0.27.0 17 | gopkg.in/yaml.v3 v3.0.1 18 | modernc.org/sqlite v1.30.1 19 | ) 20 | 21 | require ( 22 | github.com/Benau/go_rlottie v0.0.0-20210807002906-98c1b2421989 // indirect 23 | github.com/andybalholm/cascadia v1.3.2 // indirect 24 | github.com/av-elier/go-decimal-to-rational v0.0.0-20191127152832-89e6aad02ecf // indirect 25 | github.com/dustin/go-humanize v1.0.1 // indirect 26 | github.com/google/uuid v1.6.0 // indirect 27 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 28 | github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/ncruces/go-strftime v0.1.9 // indirect 31 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 32 | github.com/sizeofint/webpanimation v0.0.0-20210809145948-1d2b32119882 // indirect 33 | github.com/tidwall/match v1.1.1 // indirect 34 | github.com/tidwall/pretty v1.2.1 // indirect 35 | golang.org/x/sys v0.22.0 // indirect 36 | modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect 37 | modernc.org/libc v1.54.4 // indirect 38 | modernc.org/mathutil v1.6.0 // indirect 39 | modernc.org/memory v1.8.0 // indirect 40 | modernc.org/strutil v1.2.0 // indirect 41 | modernc.org/token v1.1.0 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /internal/manager/topic_manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "github.com/duo/octopus/internal/db" 5 | ) 6 | 7 | func init() { 8 | if _, err := db.DB.Exec(`BEGIN; 9 | CREATE TABLE IF NOT EXISTS topic ( 10 | id INTEGER PRIMARY KEY, 11 | master_limb TEXT NOT NULL, 12 | slave_limb TEXT NOT NULL, 13 | topic_id INTEGER NOT NULL, 14 | UNIQUE(master_limb, slave_limb) 15 | ); 16 | COMMIT;`); err != nil { 17 | panic(err) 18 | } 19 | } 20 | 21 | type Topic struct { 22 | ID int64 23 | MasterLimb string 24 | SlaveLimb string 25 | TopicID string 26 | } 27 | 28 | func GetTopic(master_limb, slave_limb string) (*Topic, error) { 29 | rows, err := db.DB.Query(`SELECT * FROM topic WHERE master_limb = ? AND slave_limb = ?;`, master_limb, slave_limb) 30 | 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | defer rows.Close() 36 | 37 | hasNext := rows.Next() 38 | if hasNext { 39 | t := &Topic{} 40 | err = rows.Scan(&t.ID, &t.MasterLimb, &t.SlaveLimb, &t.TopicID) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return t, err 46 | } 47 | 48 | return nil, nil 49 | } 50 | 51 | func GetTopicByMaster(master_limb string, topic_id int64) (*Topic, error) { 52 | rows, err := db.DB.Query(`SELECT * FROM topic WHERE master_limb = ? AND topic_id = ?;`, master_limb, topic_id) 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | defer rows.Close() 59 | 60 | hasNext := rows.Next() 61 | if hasNext { 62 | t := &Topic{} 63 | err = rows.Scan(&t.ID, &t.MasterLimb, &t.SlaveLimb, &t.TopicID) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | return t, err 69 | } 70 | 71 | return nil, nil 72 | } 73 | 74 | func AddTopic(t *Topic) error { 75 | _, err := db.DB.Exec( 76 | `INSERT INTO topic (master_limb, slave_limb, topic_id) VALUES (?, ?, ?);`, 77 | t.MasterLimb, t.SlaveLimb, t.TopicID, 78 | ) 79 | return err 80 | } 81 | 82 | func DelTopic(master_limb, slave_limb string) error { 83 | _, err := db.DB.Exec( 84 | `DELETE FROM link WHERE master_limb = ? AND slave_limb = ?;`, 85 | master_limb, slave_limb, 86 | ) 87 | return err 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Octopus 2 | A Telegram bot bridge other IM (qq, wechat, etc.) conversations together. 3 | 4 | ## Dependencies 5 | * go 6 | * ffmpeg (optional for qq/wechat audio) 7 | 8 | # Docker 9 | * [octopus](https://hub.docker.com/r/lxduo/octopus) 10 | ```shell 11 | docker run -d -p 11111:11111 --name=octopus --restart=always -v octopus:/data lxduo/octopus:latest 12 | ``` 13 | 14 | # Limbs 15 | * [octopus-qq](https://github.com/duo/octopus-qq) 16 | * [octopus-wechat](https://github.com/duo/octopus-wechat) 17 | * [octopus-wechat-web](https://github.com/duo/octopus-wechat-web) 18 | 19 | # Documentation 20 | 21 | ## Bot 22 | Create a bot with [@BotFather](https://t.me/botfather), get a token. 23 | Set /setjoingroups Enable and /setprivacy Disable 24 | 25 | ## Configuration 26 | * configure.yaml 27 | ```yaml 28 | master: 29 | api_url: http://10.0.0.10:8081 # Optional, Telegram local bot api server 30 | local_mode: true # Optional, local server mode 31 | admin_id: # Required, Telegram user id (administrator) 32 | token: 1234567:xxxxxxxx # Required, Telegram bot token 33 | proxy: http://1.1.1.1:7890 # Optional, proxy for Telegram 34 | page_size: 10 # Optional, command list result pagination size 35 | archive: # Optional, archive client chat by topic 36 | - vendor: wechat # qq, wechat, etc 37 | uid: wxid_xxxxxxx # client id 38 | chat_id: 123456789 # topic enabled group id (grant related permissions to bot) 39 | telegraph: # Optional 40 | enable: true # Convert some message to telegra.ph article (e.g. QQ forward message) 41 | proxy: http://1.1.1.1:7890 # Optional, proxy for telegra.ph 42 | tokens: 43 | - abcdefg # telegra.ph tokens 44 | 45 | service: 46 | addr: 0.0.0.0:11111 # Required, listen address 47 | secret: hello # Required, user defined secret 48 | send_timeout: 3m # Optional 49 | 50 | log: 51 | level: info 52 | ``` 53 | 54 | ## Command 55 | All messages will be sent to the admin directly by default, you can archive chat by topic or /link specific remote chat to a Telegram group. 56 | ``` 57 | /help Show command list. 58 | /link Manage remote chat link. 59 | /chat Generate a remote chat head. 60 | ``` 61 | -------------------------------------------------------------------------------- /internal/common/key_mutex.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 The Kubernetes Authors. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | package common 15 | 16 | import ( 17 | "hash/fnv" 18 | "runtime" 19 | "sync" 20 | ) 21 | 22 | // KeyMutex is a thread-safe interface for acquiring locks on arbitrary strings. 23 | type KeyMutex interface { 24 | // Acquires a lock associated with the specified ID, creates the lock if one doesn't already exist. 25 | LockKey(id string) 26 | 27 | // Releases the lock associated with the specified ID. 28 | // Returns an error if the specified ID doesn't exist. 29 | UnlockKey(id string) error 30 | } 31 | 32 | // NewHashed returns a new instance of KeyMutex which hashes arbitrary keys to 33 | // a fixed set of locks. `n` specifies number of locks, if n <= 0, we use 34 | // number of cpus. 35 | // Note that because it uses fixed set of locks, different keys may share same 36 | // lock, so it's possible to wait on same lock. 37 | func NewHashed(n int) KeyMutex { 38 | if n <= 0 { 39 | n = runtime.NumCPU() 40 | } 41 | return &hashedKeyMutex{ 42 | mutexes: make([]sync.Mutex, n), 43 | } 44 | } 45 | 46 | type hashedKeyMutex struct { 47 | mutexes []sync.Mutex 48 | } 49 | 50 | // Acquires a lock associated with the specified ID. 51 | func (km *hashedKeyMutex) LockKey(id string) { 52 | km.mutexes[km.hash(id)%uint32(len(km.mutexes))].Lock() 53 | } 54 | 55 | // Releases the lock associated with the specified ID. 56 | func (km *hashedKeyMutex) UnlockKey(id string) error { 57 | km.mutexes[km.hash(id)%uint32(len(km.mutexes))].Unlock() 58 | return nil 59 | } 60 | 61 | func (km *hashedKeyMutex) hash(id string) uint32 { 62 | h := fnv.New32a() 63 | h.Write([]byte(id)) 64 | return h.Sum32() 65 | } 66 | -------------------------------------------------------------------------------- /internal/manager/link_manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "github.com/duo/octopus/internal/db" 5 | ) 6 | 7 | func init() { 8 | if _, err := db.DB.Exec(`BEGIN; 9 | CREATE TABLE IF NOT EXISTS link ( 10 | id INTEGER PRIMARY KEY, 11 | master_limb TEXT NOT NULL, 12 | slave_limb TEXT NOT NULL, 13 | UNIQUE(master_limb, slave_limb) 14 | ); 15 | COMMIT;`); err != nil { 16 | panic(err) 17 | } 18 | } 19 | 20 | type Link struct { 21 | ID int64 22 | MasterLimb string 23 | SlaveLimb string 24 | Title string 25 | } 26 | 27 | func GetLinkList() ([]*Link, error) { 28 | links := []*Link{} 29 | 30 | rows, err := db.DB.Query(`SELECT 31 | l.id, l.master_limb, l.slave_limb, c.title 32 | FROM link AS l LEFT JOIN chat AS c 33 | ON l.slave_limb = c.limb;`) 34 | if err != nil { 35 | return links, err 36 | } 37 | 38 | defer rows.Close() 39 | 40 | for rows.Next() { 41 | l := &Link{} 42 | if err := rows.Scan(&l.ID, &l.MasterLimb, &l.SlaveLimb, &l.Title); err != nil { 43 | return links, err 44 | } 45 | links = append(links, l) 46 | } 47 | if err = rows.Err(); err != nil { 48 | return links, err 49 | } 50 | 51 | return links, nil 52 | } 53 | 54 | func GetLinksByMaster(masterLimb string) ([]*Link, error) { 55 | links := []*Link{} 56 | 57 | rows, err := db.DB.Query(`SELECT 58 | l.id, l.master_limb, l.slave_limb, c.title 59 | FROM link AS l LEFT JOIN chat AS c 60 | ON l.slave_limb = c.limb 61 | WHERE l.master_limb = ?;`, 62 | masterLimb, 63 | ) 64 | if err != nil { 65 | return links, err 66 | } 67 | 68 | defer rows.Close() 69 | 70 | for rows.Next() { 71 | l := &Link{} 72 | if err := rows.Scan(&l.ID, &l.MasterLimb, &l.SlaveLimb, &l.Title); err != nil { 73 | return links, err 74 | } 75 | links = append(links, l) 76 | } 77 | if err = rows.Err(); err != nil { 78 | return links, err 79 | } 80 | 81 | return links, nil 82 | } 83 | 84 | func GetLinksBySlave(slaveLimb string) ([]*Link, error) { 85 | links := []*Link{} 86 | 87 | rows, err := db.DB.Query(`SELECT 88 | l.id, l.master_limb, l.slave_limb, c.title 89 | FROM link AS l LEFT JOIN chat AS c 90 | ON l.slave_limb = c.limb 91 | WHERE l.slave_limb = ?;`, 92 | slaveLimb, 93 | ) 94 | if err != nil { 95 | return links, err 96 | } 97 | 98 | defer rows.Close() 99 | 100 | for rows.Next() { 101 | l := &Link{} 102 | if err := rows.Scan(&l.ID, &l.MasterLimb, &l.SlaveLimb, &l.Title); err != nil { 103 | return links, err 104 | } 105 | links = append(links, l) 106 | } 107 | if err = rows.Err(); err != nil { 108 | return links, err 109 | } 110 | 111 | return links, nil 112 | } 113 | 114 | func AddLink(l *Link) error { 115 | _, err := db.DB.Exec(`INSERT INTO link (master_limb, slave_limb) VALUES (?, ?);`, l.MasterLimb, l.SlaveLimb) 116 | return err 117 | } 118 | 119 | func DelLinkById(id int64) error { 120 | _, err := db.DB.Exec(`DELETE FROM link WHERE id = ?;`, id) 121 | return err 122 | } 123 | -------------------------------------------------------------------------------- /internal/manager/chat_manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/duo/octopus/internal/db" 7 | ) 8 | 9 | func init() { 10 | if _, err := db.DB.Exec(`BEGIN; 11 | CREATE TABLE IF NOT EXISTS chat ( 12 | id INTEGER PRIMARY KEY, 13 | limb TEXT NOT NULL, 14 | chat_type TEXT NOT NULL, 15 | title TEXT NOT NULL, 16 | UNIQUE(limb) 17 | ); 18 | CREATE INDEX IF NOT EXISTS idx_title ON chat (title); 19 | COMMIT;`); err != nil { 20 | panic(err) 21 | } 22 | } 23 | 24 | type Chat struct { 25 | ID int64 26 | Limb string 27 | ChatType string 28 | Title string 29 | } 30 | 31 | func AddOrUpdateChat(c *Chat) error { 32 | rows, err := db.DB.Query(`SELECT * FROM chat WHERE limb = ?;`, c.Limb) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | hasNext := rows.Next() 38 | rows.Close() 39 | if hasNext { 40 | if _, err := db.DB.Exec(`UPDATE chat SET title = ? WHERE limb = ?;`, c.Title, c.Limb); err != nil { 41 | return err 42 | } 43 | } else { 44 | if _, err := db.DB.Exec(`INSERT INTO chat (limb, chat_type, title) VALUES (?, ?, ?);`, c.Limb, c.ChatType, c.Title); err != nil { 45 | return err 46 | } 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func GetChat(limb string) (*Chat, error) { 53 | rows, err := db.DB.Query(`SELECT * FROM chat WHERE limb = ?;`, limb) 54 | 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | defer rows.Close() 60 | 61 | hasNext := rows.Next() 62 | if hasNext { 63 | c := &Chat{} 64 | err = rows.Scan(&c.ID, &c.Limb, &c.ChatType, &c.Title) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return c, err 70 | } 71 | 72 | return nil, nil 73 | } 74 | 75 | func GetChatCount(query string) (int, error) { 76 | var rows *sql.Rows 77 | var err error 78 | if len(query) > 0 { 79 | rows, err = db.DB.Query(`SELECT count(*) FROM chat WHERE title LIKE ?;`, "%"+query+"%") 80 | } else { 81 | rows, err = db.DB.Query(`SELECT count(*) FROM chat;`) 82 | } 83 | if err != nil { 84 | return 0, err 85 | } 86 | 87 | defer rows.Close() 88 | 89 | hasNext := rows.Next() 90 | if hasNext { 91 | var count int 92 | err = rows.Scan(&count) 93 | if err != nil { 94 | return 0, err 95 | } 96 | 97 | return count, err 98 | } 99 | 100 | return 0, nil 101 | } 102 | 103 | func GetChatList(pageNum, pageSize int, query string) ([]*Chat, error) { 104 | chats := []*Chat{} 105 | 106 | offset := pageSize * (pageNum - 1) 107 | var rows *sql.Rows 108 | var err error 109 | if len(query) > 0 { 110 | rows, err = db.DB.Query(`SELECT * FROM chat 111 | WHERE title LIKE ? 112 | LIMIT ?,?;`, 113 | "%"+query+"%", offset, pageSize) 114 | } else { 115 | rows, err = db.DB.Query(`SELECT * FROM chat LIMIT ?,?;`, offset, pageSize) 116 | } 117 | if err != nil { 118 | return chats, err 119 | } 120 | 121 | defer rows.Close() 122 | 123 | for rows.Next() { 124 | c := &Chat{} 125 | if err := rows.Scan(&c.ID, &c.Limb, &c.ChatType, &c.Title); err != nil { 126 | return chats, err 127 | } 128 | chats = append(chats, c) 129 | } 130 | if err = rows.Err(); err != nil { 131 | return chats, err 132 | } 133 | 134 | return chats, nil 135 | } 136 | -------------------------------------------------------------------------------- /internal/common/util.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "compress/gzip" 5 | "crypto/tls" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/gabriel-vasile/mimetype" 14 | 15 | _ "unsafe" 16 | ) 17 | 18 | var tlsCipherSuites = []uint16{ 19 | // AEADs w/ ECDHE 20 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 21 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 22 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 23 | 24 | // CBC w/ ECDHE 25 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, 26 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, 27 | 28 | // AEADs w/o ECDHE 29 | tls.TLS_RSA_WITH_AES_128_GCM_SHA256, 30 | tls.TLS_RSA_WITH_AES_256_GCM_SHA384, 31 | 32 | // CBC w/o ECDHE 33 | tls.TLS_RSA_WITH_AES_128_CBC_SHA, 34 | tls.TLS_RSA_WITH_AES_256_CBC_SHA, 35 | 36 | // 3DES 37 | tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, 38 | tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, 39 | } 40 | 41 | var ( 42 | httpClient = &http.Client{ 43 | Transport: &http.Transport{ 44 | ForceAttemptHTTP2: true, 45 | MaxConnsPerHost: 0, 46 | MaxIdleConns: 0, 47 | MaxIdleConnsPerHost: 256, 48 | TLSClientConfig: &tls.Config{ 49 | CipherSuites: tlsCipherSuites, 50 | MinVersion: tls.VersionTLS10, 51 | InsecureSkipVerify: true, 52 | }, 53 | }, 54 | } 55 | 56 | UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66" 57 | ) 58 | 59 | func Itoa(i int64) string { 60 | return strconv.FormatInt(i, 10) 61 | } 62 | 63 | func Atoi(s string) (int64, error) { 64 | return strconv.ParseInt(s, 10, 64) 65 | } 66 | 67 | func EscapeText(parseMode string, text string) string { 68 | var replacer *strings.Replacer 69 | 70 | if parseMode == "Markdown" { 71 | replacer = strings.NewReplacer("_", "\\_", "*", "\\*", "`", "\\`", "[", "\\[") 72 | } else if parseMode == "MarkdownV2" { 73 | replacer = strings.NewReplacer( 74 | "_", "\\_", "*", "\\*", "[", "\\[", "]", "\\]", "(", 75 | "\\(", ")", "\\)", "~", "\\~", "`", "\\`", ">", "\\>", 76 | "#", "\\#", "+", "\\+", "-", "\\-", "=", "\\=", "|", 77 | "\\|", "{", "\\{", "}", "\\}", ".", "\\.", "!", "\\!", 78 | ) 79 | } else { 80 | return "" 81 | } 82 | 83 | return replacer.Replace(text) 84 | } 85 | 86 | //go:linkname Uint32 runtime.fastrand 87 | func Uint32() uint32 88 | 89 | func NextRandom() string { 90 | return strconv.Itoa(int(Uint32())) 91 | } 92 | 93 | func Download(path string) (*BlobData, error) { 94 | data, err := GetBytes(path) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | blobData := &BlobData{ 100 | Mime: mimetype.Detect(data).String(), 101 | Binary: data, 102 | } 103 | 104 | if u, err := url.Parse(path); err == nil { 105 | if p, err := url.QueryUnescape(u.EscapedPath()); err == nil { 106 | blobData.Name = filepath.Base(p) 107 | } 108 | } 109 | 110 | return blobData, nil 111 | } 112 | 113 | func GetBytes(url string) ([]byte, error) { 114 | reader, err := HTTPGetReadCloser(url) 115 | if err != nil { 116 | return nil, err 117 | } 118 | defer func() { 119 | _ = reader.Close() 120 | }() 121 | 122 | return io.ReadAll(reader) 123 | } 124 | 125 | type gzipCloser struct { 126 | f io.Closer 127 | r *gzip.Reader 128 | } 129 | 130 | func NewGzipReadCloser(reader io.ReadCloser) (io.ReadCloser, error) { 131 | gzipReader, err := gzip.NewReader(reader) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | return &gzipCloser{ 137 | f: reader, 138 | r: gzipReader, 139 | }, nil 140 | } 141 | 142 | func (g *gzipCloser) Read(p []byte) (n int, err error) { 143 | return g.r.Read(p) 144 | } 145 | 146 | func (g *gzipCloser) Close() error { 147 | _ = g.f.Close() 148 | 149 | return g.r.Close() 150 | } 151 | 152 | func HTTPGetReadCloser(url string) (io.ReadCloser, error) { 153 | req, err := http.NewRequest("GET", url, nil) 154 | if err != nil { 155 | return nil, err 156 | } 157 | req.Header["User-Agent"] = []string{UserAgent} 158 | resp, err := httpClient.Do(req) 159 | if err != nil { 160 | return nil, err 161 | } 162 | if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { 163 | return NewGzipReadCloser(resp.Body) 164 | } 165 | 166 | return resp.Body, err 167 | } 168 | -------------------------------------------------------------------------------- /internal/manager/messenge_manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/duo/octopus/internal/common" 7 | "github.com/duo/octopus/internal/db" 8 | ) 9 | 10 | func init() { 11 | if _, err := db.DB.Exec(`BEGIN; 12 | CREATE TABLE IF NOT EXISTS message ( 13 | id INTEGER PRIMARY KEY, 14 | master_limb TEXT NOT NULL, 15 | master_msg_id TEXT NOT NULL, 16 | master_msg_thread_id TEXT NOT NULL, 17 | slave_limb TEXT NOT NULL, 18 | slave_msg_id TEXT NOT NULL, 19 | slave_sender TEXT NOT NULL, 20 | content TEXT NOT NULL, 21 | timestamp INTEGER NOT NULL, 22 | created DATETIME DEFAULT CURRENT_TIMESTAMP, 23 | UNIQUE(master_limb, master_msg_id) 24 | ); 25 | CREATE INDEX IF NOT EXISTS idx_slave_reply ON message (slave_limb, timestamp); 26 | CREATE INDEX IF NOT EXISTS idx_master_reply ON message (master_limb, master_msg_id); 27 | COMMIT;`); err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | type Message struct { 33 | ID string 34 | MasterLimb string 35 | MasterMsgID string 36 | MasterMsgThreadID string 37 | SlaveLimb string 38 | SlaveMsgID string 39 | SlaveSender string 40 | Content string 41 | Timestamp int64 42 | } 43 | 44 | func AddMessage(m *Message) error { 45 | _, err := db.DB.Exec(`INSERT INTO message 46 | (master_limb, master_msg_id, master_msg_thread_id, slave_limb, slave_msg_id, slave_sender, content, timestamp) 47 | VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, 48 | m.MasterLimb, m.MasterMsgID, m.MasterMsgThreadID, m.SlaveLimb, m.SlaveMsgID, m.SlaveSender, m.Content, m.Timestamp, 49 | ) 50 | return err 51 | } 52 | 53 | func GetMessageByMasterMsgId(masterLimb, masterMsgId string) (*Message, error) { 54 | rows, err := db.DB.Query(`SELECT id, master_limb, master_msg_id, master_msg_thread_id, slave_limb, slave_msg_id, slave_sender, content, timestamp 55 | FROM message 56 | WHERE master_limb = ? AND master_msg_id = ?;`, 57 | masterLimb, masterMsgId) 58 | 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | defer rows.Close() 64 | 65 | hasNext := rows.Next() 66 | if hasNext { 67 | m := &Message{} 68 | err = rows.Scan(&m.ID, &m.MasterLimb, &m.MasterMsgID, &m.MasterMsgThreadID, &m.SlaveLimb, &m.SlaveMsgID, &m.SlaveSender, &m.Content, &m.Timestamp) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return m, err 74 | } 75 | 76 | return nil, nil 77 | } 78 | 79 | func GetMessagesBySlave(slaveLimb, slaveMsgId string) ([]*Message, error) { 80 | messages := []*Message{} 81 | 82 | rows, err := db.DB.Query(`SELECT id, master_limb, master_msg_id, master_msg_thread_id, slave_limb, slave_msg_id, content 83 | FROM message 84 | WHERE slave_limb = ? AND slave_msg_id = ?;`, 85 | slaveLimb, slaveMsgId) 86 | if err != nil { 87 | return messages, err 88 | } 89 | 90 | defer rows.Close() 91 | 92 | for rows.Next() { 93 | m := &Message{} 94 | err := rows.Scan(&m.ID, &m.MasterLimb, &m.MasterMsgID, &m.MasterMsgThreadID, &m.SlaveLimb, &m.SlaveMsgID, &m.Content) 95 | if err != nil { 96 | return messages, err 97 | } 98 | messages = append(messages, m) 99 | } 100 | if err = rows.Err(); err != nil { 101 | return messages, err 102 | } 103 | 104 | return messages, nil 105 | } 106 | 107 | func GetMessagesBySlaveReply(slaveLimb string, reply *common.ReplyInfo) ([]*Message, error) { 108 | messages := []*Message{} 109 | 110 | var rows *sql.Rows 111 | var err error 112 | if reply.Timestamp == 0 { 113 | rows, err = db.DB.Query(`SELECT id, master_limb, master_msg_id, master_msg_thread_id, slave_limb, slave_msg_id, content 114 | FROM message 115 | WHERE slave_limb = ? AND slave_msg_id = ?;`, 116 | slaveLimb, reply.ID) 117 | } else { 118 | // TODO: back search? 119 | rows, err = db.DB.Query(`SELECT id, master_limb, master_msg_id, master_msg_thread_id, slave_limb, slave_msg_id, content 120 | FROM message 121 | WHERE slave_limb = ? AND timestamp = ? AND slave_msg_id LIKE ?;`, 122 | slaveLimb, reply.Timestamp, reply.ID+"%") 123 | } 124 | if err != nil { 125 | return messages, err 126 | } 127 | 128 | defer rows.Close() 129 | 130 | for rows.Next() { 131 | m := &Message{} 132 | err := rows.Scan(&m.ID, &m.MasterLimb, &m.MasterMsgID, &m.MasterMsgThreadID, &m.SlaveLimb, &m.SlaveMsgID, &m.Content) 133 | if err != nil { 134 | return messages, err 135 | } 136 | messages = append(messages, m) 137 | } 138 | if err = rows.Err(); err != nil { 139 | return messages, err 140 | } 141 | 142 | return messages, nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/master/master_service.go: -------------------------------------------------------------------------------- 1 | package master 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/duo/octopus/internal/common" 10 | 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" 14 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery" 15 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" 16 | 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | const ( 21 | updateTimeout = 7 22 | requestTimeout = 3 * time.Minute 23 | ) 24 | 25 | type MasterService struct { 26 | config *common.Configure 27 | 28 | in <-chan *common.OctopusEvent 29 | out chan<- *common.OctopusEvent 30 | 31 | client http.Client 32 | opts *gotgbot.RequestOpts 33 | bot *gotgbot.Bot 34 | updater *ext.Updater 35 | 36 | archiveChats map[string]int64 37 | 38 | mutex common.KeyMutex 39 | } 40 | 41 | func (ms *MasterService) Start() { 42 | ms.client = http.Client{} 43 | ms.opts = &gotgbot.RequestOpts{ 44 | Timeout: requestTimeout, 45 | APIURL: ms.config.Master.APIURL, 46 | } 47 | 48 | if ms.config.Master.Proxy != "" { 49 | proxyUrl, err := url.Parse(ms.config.Master.Proxy) 50 | if err != nil { 51 | log.Panic(err) 52 | } 53 | ms.client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyUrl)} 54 | } 55 | 56 | bot, err := gotgbot.NewBot(ms.config.Master.Token, &gotgbot.BotOpts{ 57 | BotClient: &gotgbot.BaseBotClient{ 58 | Client: ms.client, 59 | DefaultRequestOpts: ms.opts, 60 | }, 61 | RequestOpts: &gotgbot.RequestOpts{ 62 | Timeout: requestTimeout, 63 | APIURL: ms.config.Master.APIURL, 64 | }, 65 | }) 66 | if err != nil { 67 | log.Panic("failed to create new bot: " + err.Error()) 68 | } 69 | ms.bot = bot 70 | 71 | dispatcher := ext.NewDispatcher(&ext.DispatcherOpts{ 72 | Error: func(b *gotgbot.Bot, ctx *ext.Context, err error) ext.DispatcherAction { 73 | log.Infoln("an error occurred while handling update:", err.Error()) 74 | return ext.DispatcherActionNoop 75 | }, 76 | MaxRoutines: ext.DefaultMaxRoutines, 77 | }) 78 | ms.updater = ext.NewUpdater(dispatcher, nil) 79 | 80 | dispatcher.AddHandler(handlers.NewCallback(callbackquery.All, ms.onCallback)) 81 | dispatcher.AddHandler(handlers.NewMessage(message.All, ms.onMessage)) 82 | 83 | log.Infof("MasterService starting for %s", bot.User.Username) 84 | err = ms.updater.StartPolling(bot, &ext.PollingOpts{ 85 | DropPendingUpdates: true, 86 | GetUpdatesOpts: &gotgbot.GetUpdatesOpts{ 87 | Timeout: updateTimeout, 88 | RequestOpts: ms.opts, 89 | }, 90 | }) 91 | if err != nil { 92 | log.Panic("failed to start polling: " + err.Error()) 93 | } 94 | 95 | go ms.updater.Idle() 96 | go ms.handleSlaveLoop() 97 | } 98 | 99 | func (ms *MasterService) Stop() { 100 | log.Infoln("MasterService stopping") 101 | ms.updater.Stop() 102 | } 103 | 104 | func NewMasterService(config *common.Configure, in <-chan *common.OctopusEvent, out chan<- *common.OctopusEvent) *MasterService { 105 | archiveChats := make(map[string]int64) 106 | for _, archive := range config.Master.Archive { 107 | vendor := &common.Vendor{ 108 | Type: archive.Vendor, 109 | UID: archive.UID, 110 | } 111 | archiveChats[vendor.String()] = archive.ChatID 112 | } 113 | 114 | return &MasterService{ 115 | config: config, 116 | in: in, 117 | out: out, 118 | archiveChats: archiveChats, 119 | mutex: common.NewHashed(47), 120 | } 121 | } 122 | 123 | func (ms *MasterService) onMessage(bot *gotgbot.Bot, ctx *ext.Context) error { 124 | // Ignore bot's message 125 | if ctx.EffectiveMessage.From.IsBot { 126 | return nil 127 | } 128 | 129 | // Ignore strenger's message 130 | if ctx.EffectiveMessage.From.Id != ms.config.Master.AdminID { 131 | return nil 132 | } 133 | 134 | // Handle command 135 | if isCommand(ctx.EffectiveMessage) { 136 | return onCommand(bot, ctx, ms.config) 137 | } 138 | 139 | return ms.processMasterMessage(ctx) 140 | } 141 | 142 | func (ms *MasterService) onCallback(bot *gotgbot.Bot, ctx *ext.Context) error { 143 | cb, ok := getCallback(ctx.Update.CallbackQuery.Data) 144 | if !ok { 145 | return errors.New("failed to look up callback data") 146 | } 147 | 148 | switch cb.Category { 149 | case "link": 150 | return handleLink(bot, ctx, ms.config, ctx.Update.CallbackQuery.From.Id, cb) 151 | case "chat": 152 | return handleChat(bot, ctx, ms.config, ctx.Update.CallbackQuery.From.Id, cb) 153 | default: 154 | return errors.New("invalid callback data") 155 | } 156 | } 157 | 158 | func isCommand(msg *gotgbot.Message) bool { 159 | if msg.Entities == nil || len(msg.Entities) == 0 { 160 | return false 161 | } 162 | 163 | entity := msg.Entities[0] 164 | return entity.Offset == 0 && entity.Type == "bot_command" 165 | } 166 | -------------------------------------------------------------------------------- /internal/filter/voice_filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | 11 | "github.com/duo/octopus/internal/common" 12 | 13 | "github.com/youthlin/silk" 14 | 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const sampleRate = 24000 19 | 20 | // Telegram -> QQ/WeChat: convert opus voice to silk 21 | type VoiceM2SFilter struct { 22 | } 23 | 24 | func (f VoiceM2SFilter) Apply(event *common.OctopusEvent) *common.OctopusEvent { 25 | if event.Type == common.EventAudio { 26 | blob := event.Data.(*common.BlobData) 27 | switch event.Vendor.Type { 28 | case "qq": 29 | if data, err := ogg2silk(blob.Binary); err != nil { 30 | log.Warnf("Failed to convert ogg to silk: %v", err) 31 | } else { 32 | blob.Mime = "audio/silk" 33 | blob.Binary = data 34 | } 35 | case "wechat": 36 | if data, err := ogg2mp3(blob.Binary); err != nil { 37 | log.Warnf("Failed to convert ogg to mp3: %v", err) 38 | } else { 39 | event.Type = common.EventFile 40 | blob.Mime = "audio/mpeg" 41 | blob.Binary = data 42 | 43 | randBytes := make([]byte, 4) 44 | rand.Read(randBytes) 45 | blob.Name = fmt.Sprintf("VOICE_%s.mp3", hex.EncodeToString(randBytes)) 46 | } 47 | } 48 | } 49 | 50 | return event 51 | } 52 | 53 | // QQ/WeChat -> Telegram: convert silk voice to opus 54 | type VoiceS2MFilter struct { 55 | } 56 | 57 | func (f VoiceS2MFilter) Apply(event *common.OctopusEvent) *common.OctopusEvent { 58 | if event.Type == common.EventAudio { 59 | blob := event.Data.(*common.BlobData) 60 | if event.Vendor.Type == "qq" || event.Vendor.Type == "wechat" { 61 | if data, err := silk2ogg(blob.Binary); err != nil { 62 | log.Warnf("Failed to convert silk to ogg: %v", err) 63 | } else { 64 | blob.Mime = "audio/ogg" 65 | blob.Binary = data 66 | } 67 | } 68 | } 69 | 70 | return event 71 | } 72 | 73 | func silk2ogg(rawData []byte) ([]byte, error) { 74 | buf := bytes.NewBuffer(rawData) 75 | pcmData, err := silk.Decode(buf) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | pcmFile, err := os.CreateTemp("", "pcm-") 81 | if err != nil { 82 | return nil, err 83 | } 84 | defer os.Remove(pcmFile.Name()) 85 | os.WriteFile(pcmFile.Name(), pcmData, 0o644) 86 | 87 | wavFile, err := os.CreateTemp("", "wav-") 88 | if err != nil { 89 | return nil, err 90 | } 91 | defer os.Remove(wavFile.Name()) 92 | { 93 | cmd := exec.Command( 94 | "ffmpeg", "-f", "s16le", "-ar", "24000", "-ac", "1", "-y", "-i", pcmFile.Name(), "-f", "wav", "-af", "volume=7.812500", wavFile.Name()) 95 | if err := cmd.Start(); err != nil { 96 | return nil, err 97 | } 98 | if err := cmd.Wait(); err != nil { 99 | return nil, err 100 | } 101 | } 102 | 103 | oggFile, err := os.CreateTemp("", "ogg-") 104 | if err != nil { 105 | return nil, err 106 | } 107 | defer os.Remove(oggFile.Name()) 108 | { 109 | cmd := exec.Command( 110 | "ffmpeg", "-y", "-i", wavFile.Name(), "-c:a", "libopus", "-b:a", "24K", "-f", "ogg", oggFile.Name()) 111 | if err := cmd.Start(); err != nil { 112 | return nil, err 113 | } 114 | 115 | if err := cmd.Wait(); err != nil { 116 | return nil, err 117 | } 118 | } 119 | 120 | return os.ReadFile(oggFile.Name()) 121 | } 122 | 123 | func ogg2silk(rawData []byte) ([]byte, error) { 124 | oggFile, err := os.CreateTemp("", "ogg-") 125 | if err != nil { 126 | return nil, err 127 | } 128 | defer os.Remove(oggFile.Name()) 129 | os.WriteFile(oggFile.Name(), rawData, 0o644) 130 | 131 | wavFile, err := os.CreateTemp("", "wav-") 132 | if err != nil { 133 | return nil, err 134 | } 135 | defer os.Remove(wavFile.Name()) 136 | { 137 | cmd := exec.Command( 138 | "ffmpeg", "-y", "-i", oggFile.Name(), "-f", "s16le", "-ar", "24000", "-ac", "1", wavFile.Name()) 139 | if err := cmd.Start(); err != nil { 140 | return nil, err 141 | } 142 | if err := cmd.Wait(); err != nil { 143 | return nil, err 144 | } 145 | } 146 | 147 | wavData, err := os.Open(wavFile.Name()) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | silkData, err := silk.Encode(wavData, silk.Stx(true)) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | return silkData, nil 158 | } 159 | 160 | func ogg2mp3(rawData []byte) ([]byte, error) { 161 | oggFile, err := os.CreateTemp("", "ogg-") 162 | if err != nil { 163 | return nil, err 164 | } 165 | defer os.Remove(oggFile.Name()) 166 | os.WriteFile(oggFile.Name(), rawData, 0o644) 167 | 168 | mp3File, err := os.CreateTemp("", "mp3-") 169 | if err != nil { 170 | return nil, err 171 | } 172 | defer os.Remove(mp3File.Name()) 173 | { 174 | cmd := exec.Command("ffmpeg", "-y", "-i", oggFile.Name(), "-f", "mp3", mp3File.Name()) 175 | if err := cmd.Start(); err != nil { 176 | return nil, err 177 | } 178 | if err := cmd.Wait(); err != nil { 179 | return nil, err 180 | } 181 | } 182 | 183 | return os.ReadFile(mp3File.Name()) 184 | } 185 | -------------------------------------------------------------------------------- /internal/filter/sticker_filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/duo/octopus/internal/common" 9 | 10 | "github.com/Benau/tgsconverter/libtgsconverter" 11 | "github.com/gabriel-vasile/mimetype" 12 | "github.com/tidwall/gjson" 13 | 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // Telegram -> QQ/WeChat: convert webm and tgs image to gif 18 | type StickerM2SFilter struct { 19 | } 20 | 21 | func (f StickerM2SFilter) Apply(event *common.OctopusEvent) *common.OctopusEvent { 22 | if event.Type == common.EventPhoto || event.Type == common.EventSticker { 23 | if event.Vendor.Type == "qq" || event.Vendor.Type == "wechat" { 24 | var blob *common.BlobData 25 | if event.Type == common.EventPhoto { 26 | blob = event.Data.([]*common.BlobData)[0] 27 | } else { 28 | blob = event.Data.(*common.BlobData) 29 | } 30 | switch blob.Mime { 31 | case "video/webm": 32 | if data, err := webm2gif(blob.Binary); err != nil { 33 | log.Warnf("Failed to convert webm to gif: %v", err) 34 | } else { 35 | blob.Mime = "image/gif" 36 | blob.Name = blob.Name + ".gif" 37 | blob.Binary = data 38 | } 39 | case "video/mp4": 40 | // TODO: solve export gif over size 41 | if event.Vendor.Type == "qq" { 42 | event.Type = common.EventVideo 43 | event.Data = blob 44 | } 45 | case "application/gzip": // TGS 46 | if data, err := tgs2gif(blob.Binary); err != nil { 47 | log.Warnf("Failed to convert tgs to gif: %v", err) 48 | } else { 49 | blob.Mime = "image/gif" 50 | blob.Name = blob.Name + ".gif" 51 | blob.Binary = data 52 | } 53 | } 54 | } 55 | } 56 | 57 | return event 58 | } 59 | 60 | // QQ/WeChat -> Telegram 61 | type StickerS2MFilter struct { 62 | } 63 | 64 | func (f StickerS2MFilter) Apply(event *common.OctopusEvent) *common.OctopusEvent { 65 | if event.Type == common.EventSticker { 66 | if event.Vendor.Type == "qq" || event.Vendor.Type == "wechat" { 67 | blob := event.Data.(*common.BlobData) 68 | mime := mimetype.Detect(blob.Binary) 69 | blob.Mime = mime.String() 70 | if blob.Mime == "image/jpeg" { 71 | if data, err := jpeg2webp(blob.Binary); err != nil { 72 | log.Warnf("Failed to convert jpeg to webp: %v", err) 73 | } else { 74 | blob.Mime = "image/webp" 75 | blob.Binary = data 76 | } 77 | } else if blob.Mime == "image/gif" { 78 | if probe, err := ffprobe(blob.Binary); err == nil { 79 | if gjson.Get(probe, "streams.0.nb_frames").Int() == 1 { 80 | blob.Mime = "image/png" 81 | } 82 | } else { 83 | log.Warnf("Failed to probe gif: %v", err) 84 | } 85 | } 86 | } 87 | } 88 | 89 | return event 90 | } 91 | 92 | func ffprobe(rawData []byte) (string, error) { 93 | probeFile, err := os.CreateTemp("", "probe-") 94 | if err != nil { 95 | return "", err 96 | } 97 | defer os.Remove(probeFile.Name()) 98 | os.WriteFile(probeFile.Name(), rawData, 0o644) 99 | 100 | var out bytes.Buffer 101 | cmd := exec.Command("ffprobe", probeFile.Name(), "-show_format", "-show_streams", "-of", "json") 102 | cmd.Stdout = &out 103 | if err := cmd.Start(); err != nil { 104 | return "", err 105 | } 106 | if err := cmd.Wait(); err != nil { 107 | return "", err 108 | } 109 | 110 | return out.String(), nil 111 | } 112 | 113 | func webm2gif(rawData []byte) ([]byte, error) { 114 | webmFile, err := os.CreateTemp("", "webm-") 115 | if err != nil { 116 | return nil, err 117 | } 118 | defer os.Remove(webmFile.Name()) 119 | os.WriteFile(webmFile.Name(), rawData, 0o644) 120 | 121 | gifFile, err := os.CreateTemp("", "gif-") 122 | if err != nil { 123 | return nil, err 124 | } 125 | defer os.Remove(gifFile.Name()) 126 | { 127 | cmd := exec.Command("ffmpeg", "-y", "-i", webmFile.Name(), "-f", "gif", gifFile.Name()) 128 | if err := cmd.Start(); err != nil { 129 | return nil, err 130 | } 131 | if err := cmd.Wait(); err != nil { 132 | return nil, err 133 | } 134 | } 135 | 136 | return os.ReadFile(gifFile.Name()) 137 | } 138 | 139 | func tgs2gif(rawData []byte) ([]byte, error) { 140 | opt := libtgsconverter.NewConverterOptions() 141 | opt.SetExtension("gif") 142 | opt.SetScale(0.5) 143 | 144 | ret, err := libtgsconverter.ImportFromData(rawData, opt) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | return ret, nil 150 | } 151 | 152 | func jpeg2webp(rawData []byte) ([]byte, error) { 153 | jpegFile, err := os.CreateTemp("", "jpg-") 154 | if err != nil { 155 | return nil, err 156 | } 157 | defer os.Remove(jpegFile.Name()) 158 | os.WriteFile(jpegFile.Name(), rawData, 0o644) 159 | 160 | webpFile, err := os.CreateTemp("", "webp-") 161 | if err != nil { 162 | return nil, err 163 | } 164 | defer os.Remove(webpFile.Name()) 165 | { 166 | cmd := exec.Command("ffmpeg", "-y", "-i", jpegFile.Name(), "-c:v", "libwebp", "-lossless", "0", "-f", "webp", webpFile.Name()) 167 | if err := cmd.Start(); err != nil { 168 | return nil, err 169 | } 170 | if err := cmd.Wait(); err != nil { 171 | return nil, err 172 | } 173 | } 174 | 175 | return os.ReadFile(webpFile.Name()) 176 | } 177 | -------------------------------------------------------------------------------- /internal/slave/limb_client.go: -------------------------------------------------------------------------------- 1 | package slave 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | 10 | "github.com/duo/octopus/internal/common" 11 | "github.com/duo/octopus/internal/filter" 12 | 13 | "github.com/gorilla/websocket" 14 | 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | type LimbClient struct { 19 | vendor string 20 | config *common.Configure 21 | 22 | conn *websocket.Conn 23 | out chan<- *common.OctopusEvent 24 | 25 | s2m filter.EventFilterChain 26 | m2s filter.EventFilterChain 27 | 28 | writeLock sync.Mutex 29 | 30 | websocketRequests map[int64]chan<- *common.OctopusResponse 31 | websocketRequestsLock sync.RWMutex 32 | websocketRequestID int64 33 | 34 | mutex common.KeyMutex 35 | } 36 | 37 | func NewLimbClient(vendor string, config *common.Configure, conn *websocket.Conn, out chan<- *common.OctopusEvent) *LimbClient { 38 | log.Infof("LimbClient(%s) websocket connected", vendor) 39 | 40 | m2s := filter.NewEventFilterChain( 41 | filter.StickerM2SFilter{}, 42 | filter.VoiceM2SFilter{}, 43 | ) 44 | s2m := filter.NewEventFilterChain( 45 | filter.StickerS2MFilter{}, 46 | filter.VoiceS2MFilter{}, 47 | filter.EmoticonS2MFilter{}, 48 | ) 49 | 50 | return &LimbClient{ 51 | vendor: vendor, 52 | config: config, 53 | conn: conn, 54 | out: out, 55 | m2s: m2s, 56 | s2m: s2m, 57 | websocketRequests: make(map[int64]chan<- *common.OctopusResponse), 58 | mutex: common.NewHashed(47), 59 | } 60 | } 61 | 62 | func (lc *LimbClient) Vendor() string { 63 | return lc.vendor 64 | } 65 | 66 | // read message from limb client 67 | func (lc *LimbClient) run(stopFunc func()) { 68 | defer func() { 69 | log.Infof("LimbClient(%s) disconnected from websocket", lc.vendor) 70 | _ = lc.conn.Close() 71 | stopFunc() 72 | }() 73 | 74 | for { 75 | var msg common.OctopusMessage 76 | err := lc.conn.ReadJSON(&msg) 77 | if err != nil { 78 | log.Warnf("Error reading from websocket: %v", err) 79 | break 80 | } 81 | 82 | switch msg.Type { 83 | case common.MsgRequest: 84 | request := msg.Data.(*common.OctopusRequest) 85 | if request.Type == common.ReqPing { 86 | log.Debugln("Receive ping request") 87 | } else if request.Type == common.ReqEvent { 88 | go func() { 89 | event := request.Data.(*common.OctopusEvent) 90 | 91 | lc.mutex.LockKey(event.Chat.ID) 92 | defer lc.mutex.UnlockKey(event.Chat.ID) 93 | 94 | event = lc.s2m.Apply(event) 95 | 96 | lc.out <- event 97 | }() 98 | } else { 99 | log.Warnf("Request %s not support", request.Type) 100 | } 101 | case common.MsgResponse: 102 | lc.websocketRequestsLock.RLock() 103 | respChan, ok := lc.websocketRequests[msg.ID] 104 | lc.websocketRequestsLock.RUnlock() 105 | if ok { 106 | select { 107 | case respChan <- msg.Data.(*common.OctopusResponse): 108 | default: 109 | log.Warnf("Failed to handle response to %d: channel didn't accept response", msg.ID) 110 | } 111 | } else { 112 | log.Warnf("Dropping response to %d: unknown request ID", msg.ID) 113 | } 114 | } 115 | } 116 | } 117 | 118 | // send event to limb client, and return response 119 | func (lc *LimbClient) SendEvent(event *common.OctopusEvent) (*common.OctopusEvent, error) { 120 | ctx, cancel := context.WithTimeout(context.Background(), lc.config.Service.SendTiemout) 121 | defer cancel() 122 | 123 | event = lc.m2s.Apply(event) 124 | 125 | if data, err := lc.request(ctx, &common.OctopusRequest{ 126 | Type: common.ReqEvent, 127 | Data: event, 128 | }); err != nil { 129 | return nil, err 130 | } else { 131 | return data.(*common.OctopusEvent), nil 132 | } 133 | } 134 | 135 | func (lc *LimbClient) request(ctx context.Context, req *common.OctopusRequest) (any, error) { 136 | msg := &common.OctopusMessage{ 137 | ID: atomic.AddInt64(&lc.websocketRequestID, 1), 138 | Type: common.MsgRequest, 139 | Data: req, 140 | } 141 | respChan := make(chan *common.OctopusResponse, 1) 142 | 143 | lc.addWebsocketResponseWaiter(msg.ID, respChan) 144 | defer lc.removeWebsocketResponseWaiter(msg.ID, respChan) 145 | 146 | log.Debugf("Send request message #%d %s", msg.ID, req.Type) 147 | if err := lc.sendMessage(msg); err != nil { 148 | return nil, err 149 | } 150 | 151 | select { 152 | case resp := <-respChan: 153 | if resp.Error != nil { 154 | return nil, resp.Error 155 | } else { 156 | switch resp.Type { 157 | case common.RespClosed: 158 | return nil, ErrWebsocketClosed 159 | case common.RespEvent: 160 | log.Debugf("Receive response for #%d %s", msg.ID, req.Type) 161 | return resp.Data, nil 162 | default: 163 | return nil, fmt.Errorf("response %s not support", resp.Type) 164 | } 165 | } 166 | case <-ctx.Done(): 167 | return nil, ctx.Err() 168 | } 169 | } 170 | 171 | func (lc *LimbClient) sendMessage(msg *common.OctopusMessage) error { 172 | conn := lc.conn 173 | if msg == nil { 174 | return nil 175 | } else if conn == nil { 176 | return ErrWebsocketNotConnected 177 | } 178 | lc.writeLock.Lock() 179 | defer lc.writeLock.Unlock() 180 | _ = conn.SetWriteDeadline(time.Now().Add(lc.config.Service.SendTiemout)) 181 | return conn.WriteJSON(msg) 182 | } 183 | 184 | func (lc *LimbClient) addWebsocketResponseWaiter(reqID int64, waiter chan<- *common.OctopusResponse) { 185 | lc.websocketRequestsLock.Lock() 186 | lc.websocketRequests[reqID] = waiter 187 | lc.websocketRequestsLock.Unlock() 188 | } 189 | 190 | func (lc *LimbClient) removeWebsocketResponseWaiter(reqID int64, waiter chan<- *common.OctopusResponse) { 191 | lc.websocketRequestsLock.Lock() 192 | existingWaiter, ok := lc.websocketRequests[reqID] 193 | if ok && existingWaiter == waiter { 194 | delete(lc.websocketRequests, reqID) 195 | } 196 | lc.websocketRequestsLock.Unlock() 197 | close(waiter) 198 | } 199 | 200 | func (lc *LimbClient) Dispose() { 201 | oldConn := lc.conn 202 | if oldConn == nil { 203 | return 204 | } 205 | msg := websocket.FormatCloseMessage( 206 | websocket.CloseGoingAway, 207 | fmt.Sprintf(`{"type": %d, "data": {"type": %d, "data": "server_shutting_down"}}`, common.MsgRequest, common.ReqDisconnect), 208 | ) 209 | _ = oldConn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(3*time.Second)) 210 | _ = oldConn.Close() 211 | } 212 | -------------------------------------------------------------------------------- /internal/slave/limb_service.go: -------------------------------------------------------------------------------- 1 | package slave 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "runtime/debug" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/duo/octopus/internal/common" 14 | 15 | "github.com/gorilla/websocket" 16 | 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | var ( 21 | errMissingToken = common.ErrorResponse{ 22 | HTTPStatus: http.StatusForbidden, 23 | Code: "M_MISSING_TOKEN", 24 | Message: "Missing authorization header", 25 | } 26 | errUnknownToken = common.ErrorResponse{ 27 | HTTPStatus: http.StatusForbidden, 28 | Code: "M_UNKNOWN_TOKEN", 29 | Message: "Unknown authorization token", 30 | } 31 | errMissingVendor = common.ErrorResponse{ 32 | HTTPStatus: http.StatusForbidden, 33 | Code: "M_MISSING_VENDOR", 34 | Message: "Missing vendor header", 35 | } 36 | 37 | ErrWebsocketNotConnected = errors.New("websocket not connected") 38 | ErrWebsocketClosed = errors.New("websocket closed before response received") 39 | 40 | upgrader = websocket.Upgrader{} 41 | ) 42 | 43 | type LimbService struct { 44 | config *common.Configure 45 | 46 | in <-chan *common.OctopusEvent 47 | out chan<- *common.OctopusEvent 48 | 49 | server *http.Server 50 | 51 | clients map[string]Client 52 | clientsLock sync.Mutex 53 | 54 | mutex common.KeyMutex 55 | } 56 | 57 | // handle client connnection 58 | func (ls *LimbService) ServeHTTP(w http.ResponseWriter, r *http.Request) { 59 | if strings.HasPrefix(r.URL.Path, "/onebot/") { 60 | ls.handleOnebotConnection(w, r) 61 | return 62 | } 63 | 64 | ls.handleLimbConnection(w, r) 65 | } 66 | 67 | // handle limb client connnection 68 | func (ls *LimbService) handleLimbConnection(w http.ResponseWriter, r *http.Request) { 69 | authHeader := r.Header.Get("Authorization") 70 | if !strings.HasPrefix(authHeader, "Basic ") { 71 | errMissingToken.Write(w) 72 | return 73 | } 74 | 75 | if authHeader[len("Basic "):] != ls.config.Service.Secret { 76 | errUnknownToken.Write(w) 77 | return 78 | } 79 | 80 | vendor := r.Header.Get("Vendor") 81 | if vendor == "" { 82 | errMissingVendor.Write(w) 83 | return 84 | } 85 | 86 | conn, err := upgrader.Upgrade(w, r, nil) 87 | if err != nil { 88 | log.Warnf("Failed to upgrade websocket request: %v", err) 89 | return 90 | } 91 | 92 | ls.observe(fmt.Sprintf("LimbClient(%s) connected", vendor)) 93 | 94 | lc := NewLimbClient(vendor, ls.config, conn, ls.out) 95 | ls.clientsLock.Lock() 96 | ls.clients[vendor] = lc 97 | ls.clientsLock.Unlock() 98 | lc.run(func() { 99 | ls.observe(fmt.Sprintf("LimbClient(%s) disconnected", vendor)) 100 | ls.clientsLock.Lock() 101 | delete(ls.clients, vendor) 102 | ls.clientsLock.Unlock() 103 | }) 104 | } 105 | 106 | // handle onebot client connnection 107 | func (ls *LimbService) handleOnebotConnection(w http.ResponseWriter, r *http.Request) { 108 | authHeader := r.Header.Get("Authorization") 109 | if !strings.HasPrefix(authHeader, "Bearer ") { 110 | errMissingToken.Write(w) 111 | return 112 | } 113 | 114 | if authHeader[len("Bearer "):] != ls.config.Service.Secret { 115 | errUnknownToken.Write(w) 116 | return 117 | } 118 | 119 | selfID := r.Header.Get("X-Self-Id") 120 | if selfID == "" { 121 | errMissingVendor.Write(w) 122 | return 123 | } 124 | vendor := common.Vendor{ 125 | Type: r.URL.Path[8:], 126 | UID: selfID, 127 | } 128 | 129 | conn, err := upgrader.Upgrade(w, r, nil) 130 | if err != nil { 131 | log.Warnf("Failed to upgrade websocket request: %v", err) 132 | return 133 | } 134 | 135 | ls.observe(fmt.Sprintf("OnebotClient(%s) connected", vendor)) 136 | 137 | oc := NewOnebotClient(&vendor, r.Header.Get("User-Agent"), ls.config, conn, ls.out) 138 | ls.clientsLock.Lock() 139 | ls.clients[vendor.String()] = oc 140 | ls.clientsLock.Unlock() 141 | oc.run(func() { 142 | ls.observe(fmt.Sprintf("OnebotClient(%s) disconnected", vendor)) 143 | ls.clientsLock.Lock() 144 | delete(ls.clients, vendor.String()) 145 | ls.clientsLock.Unlock() 146 | }) 147 | } 148 | 149 | func (ls *LimbService) Start() { 150 | log.Infoln("LimbService starting to listen on", ls.config.Service.Addr) 151 | go func() { 152 | err := ls.server.ListenAndServe() 153 | if err != nil && err != http.ErrServerClosed { 154 | log.Fatalln("Error in listener:", err) 155 | } 156 | }() 157 | 158 | go ls.handleMasterLoop() 159 | } 160 | 161 | func (ls *LimbService) Stop() { 162 | log.Infoln("LimbService stopping") 163 | ls.clientsLock.Lock() 164 | for _, client := range ls.clients { 165 | client.Dispose() 166 | } 167 | ls.clientsLock.Unlock() 168 | 169 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 170 | defer cancel() 171 | err := ls.server.Shutdown(ctx) 172 | if err != nil { 173 | log.Warnf("Failed to close server: %v", err) 174 | } 175 | } 176 | 177 | func NewLimbService(config *common.Configure, in <-chan *common.OctopusEvent, out chan<- *common.OctopusEvent) *LimbService { 178 | service := &LimbService{ 179 | config: config, 180 | in: in, 181 | out: out, 182 | clients: make(map[string]Client), 183 | mutex: common.NewHashed(47), 184 | } 185 | service.server = &http.Server{ 186 | Addr: service.config.Service.Addr, 187 | Handler: service, 188 | } 189 | 190 | return service 191 | } 192 | 193 | // read events from master 194 | func (ls *LimbService) handleMasterLoop() { 195 | defer func() { 196 | panicErr := recover() 197 | if panicErr != nil { 198 | log.Errorf("Panic in handle master loop: %v\n%s", panicErr, debug.Stack()) 199 | } 200 | }() 201 | 202 | for event := range ls.in { 203 | vendor := event.Vendor.String() 204 | ls.clientsLock.Lock() 205 | client, ok := ls.clients[vendor] 206 | ls.clientsLock.Unlock() 207 | 208 | if ok { 209 | event := event 210 | go func() { 211 | ls.mutex.LockKey(event.Chat.ID) 212 | defer ls.mutex.UnlockKey(event.Chat.ID) 213 | 214 | ls.handleEvent(client, event) 215 | }() 216 | } else { 217 | go event.Callback(nil, fmt.Errorf("LimbClient(%s) not found", vendor)) 218 | } 219 | } 220 | } 221 | 222 | func (ls *LimbService) handleEvent(client Client, event *common.OctopusEvent) { 223 | if resp, err := client.SendEvent(event); err != nil { 224 | sendErr := fmt.Errorf("failed to send event to %s: %v", client.Vendor(), err) 225 | event.Callback(nil, sendErr) 226 | } else { 227 | event.ID = resp.ID 228 | event.Timestamp = resp.Timestamp 229 | event.Callback(event, nil) 230 | } 231 | } 232 | 233 | func (ls *LimbService) observe(msg string) { 234 | go func() { 235 | ls.out <- &common.OctopusEvent{ 236 | Type: common.EventObserve, 237 | Content: msg, 238 | } 239 | }() 240 | } 241 | -------------------------------------------------------------------------------- /internal/master/telegraph.go: -------------------------------------------------------------------------------- 1 | package master 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "mime/multipart" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | 16 | "github.com/duo/octopus/internal/common" 17 | 18 | "github.com/PuerkitoBio/goquery" 19 | "golang.org/x/net/html" 20 | 21 | log "github.com/sirupsen/logrus" 22 | ) 23 | 24 | const ( 25 | createPageURL = "https://api.telegra.ph/createPage" 26 | uploadURL = "https://telegra.ph/upload" 27 | ) 28 | 29 | var ( 30 | once sync.Once 31 | client *http.Client 32 | ) 33 | 34 | type uploadResult struct { 35 | Source []source 36 | } 37 | 38 | type source struct { 39 | Src string `json:"src"` 40 | } 41 | 42 | type uploadError struct { 43 | Error string `json:"error"` 44 | } 45 | 46 | type Node any 47 | 48 | type NodeElement struct { 49 | Tag string `json:"tag"` 50 | Attrs map[string]string `json:"attrs,omitempty"` 51 | Children []Node `json:"children,omitempty"` 52 | } 53 | 54 | type APIResponse struct { 55 | Ok bool `json:"ok"` 56 | Error string `json:"error,omitempty"` 57 | } 58 | 59 | type APIResponsePage struct { 60 | APIResponse 61 | Result Page `json:"result,omitempty"` 62 | } 63 | 64 | type Page struct { 65 | Path string `json:"path"` 66 | URL string `json:"url"` 67 | Title string `json:"title"` 68 | Description string `json:"description"` 69 | AuthorName string `json:"author_name,omitempty"` 70 | AuthorURL string `json:"author_url,omitempty"` 71 | ImageURL string `json:"image_url,omitempty"` 72 | Content []Node `json:"content,omitempty"` 73 | Views int `json:"views"` 74 | CanEdit bool `json:"can_edit,omitempty"` 75 | } 76 | 77 | func (ms *MasterService) postApp(app *common.AppData) (*Page, error) { 78 | client = getClient(ms.config.Master.Telegraph.Proxy) 79 | 80 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(app.Content)) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return createPage( 86 | doc.Contents(), 87 | ms.config.Master.Telegraph.Tokens[0], 88 | app.Title, 89 | app.Blobs, 90 | ) 91 | } 92 | 93 | func createPage(selections *goquery.Selection, token string, title string, blobs map[string]*common.BlobData) (page *Page, err error) { 94 | nodes := traverseNodes(selections, token, title, blobs) 95 | 96 | params := map[string]interface{}{ 97 | "access_token": token, 98 | "title": title, 99 | "content": castNodes(nodes), 100 | } 101 | 102 | var data []byte 103 | if data, err = apiPost(createPageURL, params); err == nil { 104 | var res APIResponsePage 105 | if err = json.Unmarshal(data, &res); err == nil { 106 | if res.Ok { 107 | return &res.Result, nil 108 | } 109 | 110 | err = fmt.Errorf(res.Error) 111 | 112 | log.Warnf("API error from %s (%+v): %v", createPageURL, params, err) 113 | } 114 | } 115 | 116 | return &Page{}, err 117 | } 118 | 119 | func castNodes(nodes []Node) []interface{} { 120 | castNodes := []interface{}{} 121 | 122 | for _, node := range nodes { 123 | switch node.(type) { 124 | case NodeElement: 125 | castNodes = append(castNodes, node) 126 | default: 127 | if cast, ok := node.(string); ok { 128 | castNodes = append(castNodes, cast) 129 | } else { 130 | log.Warnf("param casting error: %#+v", node) 131 | } 132 | } 133 | } 134 | 135 | return castNodes 136 | } 137 | 138 | func traverseNodes(selections *goquery.Selection, token, title string, blobs map[string]*common.BlobData) []Node { 139 | nodes := []Node{} 140 | 141 | var tag string 142 | var attrs map[string]string 143 | var element NodeElement 144 | 145 | selections.Each(func(_ int, child *goquery.Selection) { 146 | for _, node := range child.Nodes { 147 | switch node.Type { 148 | case html.TextNode: 149 | nodes = append(nodes, node.Data) 150 | case html.ElementNode: 151 | attrs = map[string]string{} 152 | for _, attr := range node.Attr { 153 | attrs[attr.Key] = attr.Val 154 | } 155 | 156 | if node.Data == "blockquote" { 157 | if page, err := createPage(child.Contents(), token, fmt.Sprintf("%s-%s", title, common.NextRandom()), blobs); err == nil { 158 | element = NodeElement{ 159 | Tag: "a", 160 | Attrs: map[string]string{"href": page.URL}, 161 | Children: []Node{page.Title}, 162 | } 163 | nodes = append(nodes, element) 164 | continue 165 | } else { 166 | element = NodeElement{ 167 | Tag: node.Data, 168 | Attrs: attrs, 169 | Children: []Node{page.Title}, 170 | } 171 | nodes = append(nodes, element) 172 | continue 173 | } 174 | } 175 | 176 | if node.Data == "img" && strings.HasPrefix(attrs["src"], common.REMOTE_PREFIX) { 177 | parts := strings.Split(attrs["src"], common.REMOTE_PREFIX) 178 | if len(parts) == 2 { 179 | if url, err := upload(client, blobs[parts[1]]); err == nil { 180 | attrs["src"] = url 181 | element = NodeElement{ 182 | Tag: node.Data, 183 | Attrs: attrs, 184 | } 185 | nodes = append(nodes, element) 186 | continue 187 | } else { 188 | log.Errorf("Failed to upload image to telegra.ph: %v", err) 189 | } 190 | } 191 | 192 | nodes = append(nodes, "[图片]") 193 | continue 194 | } 195 | 196 | if len(node.Namespace) > 0 { 197 | tag = fmt.Sprintf("%s.%s", node.Namespace, node.Data) 198 | } else { 199 | tag = node.Data 200 | if node.Data == "ul" || node.Data == "li" { 201 | tag = "p" 202 | } else { 203 | tag = node.Data 204 | } 205 | } 206 | element = NodeElement{ 207 | Tag: tag, 208 | Attrs: attrs, 209 | Children: traverseNodes(child.Contents(), token, title, blobs), 210 | } 211 | 212 | nodes = append(nodes, element) 213 | default: 214 | continue 215 | } 216 | } 217 | }) 218 | 219 | return nodes 220 | } 221 | 222 | func upload(c *http.Client, blob *common.BlobData) (string, error) { 223 | if blob == nil { 224 | return "", errors.New("blob not found") 225 | } 226 | 227 | b := &bytes.Buffer{} 228 | w := multipart.NewWriter(b) 229 | 230 | part, err := w.CreateFormFile("file", blob.Name) 231 | if err != nil { 232 | return "", err 233 | } 234 | part.Write(blob.Binary) 235 | w.Close() 236 | 237 | r, err := http.NewRequest("POST", uploadURL, bytes.NewReader(b.Bytes())) 238 | if err != nil { 239 | return "", err 240 | } 241 | r.Header.Set("Content-Type", w.FormDataContentType()) 242 | resp, err := c.Do(r) 243 | if err != nil { 244 | return "", err 245 | } 246 | defer resp.Body.Close() 247 | 248 | content, err := io.ReadAll(resp.Body) 249 | if err != nil { 250 | return "", err 251 | } 252 | 253 | var jsonData uploadResult 254 | json.Unmarshal(content, &jsonData.Source) 255 | if jsonData.Source == nil { 256 | var err uploadError 257 | json.Unmarshal(content, &err) 258 | return "", fmt.Errorf(err.Error) 259 | } 260 | return jsonData.Source[0].Src, err 261 | } 262 | 263 | func apiPost(apiURL string, params map[string]interface{}) (data []byte, err error) { 264 | var js []byte 265 | paramValues := url.Values{} 266 | for key, value := range params { 267 | switch v := value.(type) { 268 | case string: 269 | paramValues[key] = []string{v} 270 | default: 271 | if js, err = json.Marshal(v); err == nil { 272 | paramValues[key] = []string{string(js)} 273 | } else { 274 | log.Warnf("param marshalling error for: %s (%v)", key, err) 275 | return []byte{}, err 276 | } 277 | } 278 | } 279 | encoded := paramValues.Encode() 280 | 281 | var req *http.Request 282 | if req, err = http.NewRequest("POST", apiURL, bytes.NewBufferString(encoded)); err == nil { 283 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 284 | req.Header.Add("Content-Length", strconv.Itoa(len(encoded))) 285 | 286 | var res *http.Response 287 | res, err = client.Do(req) 288 | 289 | if err == nil { 290 | defer res.Body.Close() 291 | if data, err = io.ReadAll(res.Body); err == nil { 292 | return data, nil 293 | } 294 | 295 | log.Warnf("response read error: %v", err) 296 | } else { 297 | log.Warnf("request error: %v", err) 298 | } 299 | } else { 300 | log.Warnf("building request error: %v", err) 301 | } 302 | 303 | return []byte{}, err 304 | } 305 | 306 | func getClient(proxy string) *http.Client { 307 | once.Do(func() { 308 | client = &http.Client{} 309 | 310 | if proxy != "" { 311 | proxyUrl, err := url.Parse(proxy) 312 | if err != nil { 313 | log.Fatal(err) 314 | } 315 | client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyUrl)} 316 | } 317 | }) 318 | return client 319 | } 320 | -------------------------------------------------------------------------------- /internal/common/protocol.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | VENDOR_SEP = ";" 13 | REMOTE_PREFIX = "remote:" 14 | ) 15 | 16 | type OctopusMessage struct { 17 | ID int64 `json:"id,omitempty"` 18 | Type MessageType `json:"type,omitempty"` 19 | Data any `json:"data,omitempty"` 20 | } 21 | 22 | type OctopusRequest struct { 23 | Type RequestType `json:"type,omitempty"` 24 | Data any `json:"data,omitempty"` 25 | } 26 | 27 | type OctopusResponse struct { 28 | Type ResponseType `json:"type,omitempty"` 29 | Error *ErrorResponse `json:"error,omitempty"` 30 | Data any `json:"data,omitempty"` 31 | } 32 | 33 | type ErrorResponse struct { 34 | HTTPStatus int `json:"-"` 35 | Code string `json:"code"` 36 | Message string `json:"message"` 37 | } 38 | 39 | type OctopusEvent struct { 40 | Vendor Vendor `json:"vendor,omitempty"` 41 | ID string `json:"id,omitempty"` 42 | ThreadID string `json:"thread_id,omitempty"` 43 | Timestamp int64 `json:"timestamp,omitempty"` 44 | From User `json:"from,omitempty"` 45 | Chat Chat `json:"chat,omitempty"` 46 | Type EventType `json:"type,omitempty"` 47 | Content string `json:"content,omitempty"` 48 | Reply *ReplyInfo `json:"reply,omitempty"` 49 | Data any `json:"data,omitempty"` 50 | 51 | Callback func(*OctopusEvent, error) `json:"-"` 52 | } 53 | 54 | type Vendor struct { 55 | Type string `json:"type,omitempty"` 56 | UID string `json:"uid,omitempty"` 57 | } 58 | 59 | type User struct { 60 | ID string `json:"id,omitempty"` 61 | Username string `json:"username,omitempty"` 62 | Remark string `json:"remark,omitempty"` 63 | } 64 | 65 | type Chat struct { 66 | ID string `json:"id,omitempty"` 67 | Type string `json:"type,omitempty"` 68 | Title string `json:"title,omitempty"` 69 | } 70 | 71 | type ReplyInfo struct { 72 | ID string `json:"id"` 73 | Timestamp int64 `json:"ts"` 74 | Sender string `json:"sender"` 75 | Content string `json:"content"` 76 | } 77 | 78 | type AppData struct { 79 | Title string `json:"title,omitempty"` 80 | Description string `json:"desc,omitempty"` 81 | Source string `json:"source,omitempty"` 82 | URL string `json:"url,omitempty"` 83 | 84 | Content string `json:"raw,omitempty"` 85 | Blobs map[string]*BlobData `json:"blobs,omitempty"` 86 | } 87 | 88 | type LocationData struct { 89 | Name string `json:"name,omitempty"` 90 | Address string `json:"address,omitempty"` 91 | Longitude float64 `json:"longitude,omitempty"` 92 | Latitude float64 `json:"latitude,omitempty"` 93 | } 94 | 95 | type BlobData struct { 96 | Name string `json:"name,omitempty"` 97 | Mime string `json:"mime,omitempty"` 98 | Binary []byte `json:"binary,omitempty"` 99 | } 100 | 101 | func (o *OctopusMessage) UnmarshalJSON(data []byte) error { 102 | type cloneType OctopusMessage 103 | 104 | rawMsg := json.RawMessage{} 105 | o.Data = &rawMsg 106 | 107 | if err := json.Unmarshal(data, (*cloneType)(o)); err != nil { 108 | return err 109 | } 110 | 111 | switch o.Type { 112 | case MsgRequest: 113 | var request *OctopusRequest 114 | if err := json.Unmarshal(rawMsg, &request); err != nil { 115 | return err 116 | } 117 | o.Data = request 118 | case MsgResponse: 119 | var response *OctopusResponse 120 | if err := json.Unmarshal(rawMsg, &response); err != nil { 121 | return err 122 | } 123 | o.Data = response 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func (o *OctopusRequest) UnmarshalJSON(data []byte) error { 130 | type cloneType OctopusRequest 131 | 132 | rawMsg := json.RawMessage{} 133 | o.Data = &rawMsg 134 | 135 | if err := json.Unmarshal(data, (*cloneType)(o)); err != nil { 136 | return err 137 | } 138 | 139 | switch o.Type { 140 | case ReqEvent: 141 | var event *OctopusEvent 142 | if err := json.Unmarshal(rawMsg, &event); err != nil { 143 | return err 144 | } 145 | o.Data = event 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func (o *OctopusResponse) UnmarshalJSON(data []byte) error { 152 | type cloneType OctopusResponse 153 | 154 | rawMsg := json.RawMessage{} 155 | o.Data = &rawMsg 156 | 157 | if err := json.Unmarshal(data, (*cloneType)(o)); err != nil { 158 | return err 159 | } 160 | 161 | if o.Error != nil { 162 | return nil 163 | } 164 | 165 | switch o.Type { 166 | case RespEvent: 167 | var event *OctopusEvent 168 | if err := json.Unmarshal(rawMsg, &event); err != nil { 169 | return err 170 | } 171 | o.Data = event 172 | default: 173 | var data string 174 | if err := json.Unmarshal(rawMsg, &data); err != nil { 175 | return err 176 | } 177 | o.Data = data 178 | } 179 | 180 | return nil 181 | } 182 | 183 | func (o *OctopusEvent) UnmarshalJSON(data []byte) error { 184 | type cloneType OctopusEvent 185 | 186 | rawMsg := json.RawMessage{} 187 | o.Data = &rawMsg 188 | 189 | if err := json.Unmarshal(data, (*cloneType)(o)); err != nil { 190 | return err 191 | } 192 | 193 | switch o.Type { 194 | case EventPhoto: 195 | var photos []*BlobData 196 | if err := json.Unmarshal(rawMsg, &photos); err != nil { 197 | return err 198 | } 199 | o.Data = photos 200 | case EventSticker, EventAudio, EventVideo, EventFile: 201 | var blob *BlobData 202 | if err := json.Unmarshal(rawMsg, &blob); err != nil { 203 | return err 204 | } 205 | o.Data = blob 206 | case EventLocation: 207 | var location *LocationData 208 | if err := json.Unmarshal(rawMsg, &location); err != nil { 209 | return err 210 | } 211 | o.Data = location 212 | case EventApp: 213 | var app *AppData 214 | if err := json.Unmarshal(rawMsg, &app); err != nil { 215 | return err 216 | } 217 | o.Data = app 218 | case EventSync: 219 | var chats []*Chat 220 | if err := json.Unmarshal(rawMsg, &chats); err != nil { 221 | return err 222 | } 223 | o.Data = chats 224 | } 225 | 226 | return nil 227 | } 228 | 229 | const ( 230 | MsgRequest MessageType = iota 231 | MsgResponse 232 | ) 233 | 234 | const ( 235 | ReqDisconnect RequestType = iota 236 | ReqPing 237 | ReqEvent 238 | ) 239 | 240 | const ( 241 | RespClosed ResponseType = iota 242 | RespPing 243 | RespEvent 244 | ) 245 | 246 | const ( 247 | EventText EventType = iota 248 | EventPhoto 249 | EventAudio 250 | EventVideo 251 | EventFile 252 | EventLocation 253 | EventNotice 254 | EventApp 255 | EventRevoke 256 | EventVoIP 257 | EventSystem 258 | EventSync 259 | EventObserve 260 | EventSticker 261 | ) 262 | 263 | type MessageType int 264 | 265 | func (t MessageType) String() string { 266 | switch t { 267 | case MsgRequest: 268 | return "request" 269 | case MsgResponse: 270 | return "response" 271 | default: 272 | return "unknown" 273 | } 274 | } 275 | 276 | type RequestType int 277 | 278 | func (t RequestType) String() string { 279 | switch t { 280 | case ReqDisconnect: 281 | return "disconnect" 282 | case ReqPing: 283 | return "ping" 284 | case ReqEvent: 285 | return "event" 286 | default: 287 | return "unknown" 288 | } 289 | } 290 | 291 | type ResponseType int 292 | 293 | func (t ResponseType) String() string { 294 | switch t { 295 | case RespClosed: 296 | return "closed" 297 | case RespPing: 298 | return "ping" 299 | case RespEvent: 300 | return "event" 301 | default: 302 | return "unknown" 303 | } 304 | } 305 | 306 | type EventType int 307 | 308 | func (t EventType) String() string { 309 | switch t { 310 | case EventText: 311 | return "text" 312 | case EventPhoto: 313 | return "photo" 314 | case EventAudio: 315 | return "audio" 316 | case EventVideo: 317 | return "video" 318 | case EventFile: 319 | return "file" 320 | case EventLocation: 321 | return "location" 322 | case EventNotice: 323 | return "notice" 324 | case EventApp: 325 | return "app" 326 | case EventRevoke: 327 | return "revoke" 328 | case EventVoIP: 329 | return "voip" 330 | case EventSystem: 331 | return "system" 332 | case EventSync: 333 | return "sync" 334 | case EventObserve: 335 | return "observe" 336 | case EventSticker: 337 | return "sticker" 338 | default: 339 | return "unknown" 340 | } 341 | } 342 | 343 | func (v Vendor) String() string { 344 | return fmt.Sprintf("%s%s%s", v.Type, VENDOR_SEP, v.UID) 345 | } 346 | 347 | func VendorFromString(str string) (*Vendor, error) { 348 | parts := strings.Split(str, VENDOR_SEP) 349 | if len(parts) != 2 { 350 | return nil, errors.New("vendor format invalid") 351 | } 352 | 353 | return &Vendor{parts[0], parts[1]}, nil 354 | } 355 | 356 | func (er *ErrorResponse) Error() string { 357 | return fmt.Sprintf("%s: %s", er.Code, er.Message) 358 | } 359 | 360 | func (er ErrorResponse) Write(w http.ResponseWriter) { 361 | w.Header().Add("Content-Type", "application/json") 362 | w.WriteHeader(er.HTTPStatus) 363 | _ = Respond(w, &er) 364 | } 365 | 366 | func Respond(w http.ResponseWriter, data any) error { 367 | w.Header().Add("Content-Type", "application/json") 368 | dataStr, err := json.Marshal(data) 369 | if err != nil { 370 | return err 371 | } 372 | _, err = w.Write(dataStr) 373 | return err 374 | } 375 | -------------------------------------------------------------------------------- /internal/filter/emoticon_filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/duo/octopus/internal/common" 7 | ) 8 | 9 | var qqReplacer = strings.NewReplacer( 10 | "[Face0]", "惊讶", 11 | "[Face1]", "撇嘴", 12 | "[Face2]", "色", 13 | "[Face3]", "发呆", 14 | "[Face4]", "得意", 15 | "[Face5]", "流泪", 16 | "[Face6]", "害羞", 17 | "[Face7]", "闭嘴", 18 | "[Face8]", "睡", 19 | "[Face9]", "大哭", 20 | "[Face10]", "尴尬", 21 | "[Face11]", "发怒", 22 | "[Face12]", "调皮", 23 | "[Face13]", "呲牙", 24 | "[Face14]", "微笑", 25 | "[Face15]", "难过", 26 | "[Face16]", "酷", 27 | "[Face18]", "抓狂", 28 | "[Face19]", "吐", 29 | "[Face20]", "偷笑", 30 | "[Face21]", "可爱", 31 | "[Face22]", "白眼", 32 | "[Face23]", "傲慢", 33 | "[Face24]", "饥饿", 34 | "[Face25]", "困", 35 | "[Face26]", "惊恐", 36 | "[Face27]", "流汗", 37 | "[Face28]", "憨笑", 38 | "[Face29]", "悠闲", 39 | "[Face30]", "奋斗", 40 | "[Face31]", "咒骂", 41 | "[Face32]", "疑问", 42 | "[Face33]", "嘘", 43 | "[Face34]", "晕", 44 | "[Face35]", "折磨", 45 | "[Face36]", "衰", 46 | "[Face37]", "骷髅", 47 | "[Face38]", "敲打", 48 | "[Face39]", "再见", 49 | "[Face41]", "发抖", 50 | "[Face42]", "爱情", 51 | "[Face43]", "跳跳", 52 | "[Face46]", "猪头", 53 | "[Face49]", "拥抱", 54 | "[Face53]", "蛋糕", 55 | "[Face54]", "闪电", 56 | "[Face55]", "炸弹", 57 | "[Face56]", "刀", 58 | "[Face57]", "足球", 59 | "[Face59]", "便便", 60 | "[Face60]", "咖啡", 61 | "[Face61]", "饭", 62 | "[Face63]", "玫瑰", 63 | "[Face64]", "凋谢", 64 | "[Face66]", "爱心", 65 | "[Face67]", "心碎", 66 | "[Face69]", "礼物", 67 | "[Face74]", "太阳", 68 | "[Face75]", "月亮", 69 | "[Face76]", "赞", 70 | "[Face77]", "踩", 71 | "[Face78]", "握手", 72 | "[Face79]", "胜利", 73 | "[Face85]", "飞吻", 74 | "[Face86]", "怄火", 75 | "[Face89]", "西瓜", 76 | "[Face96]", "冷汗", 77 | "[Face97]", "擦汗", 78 | "[Face98]", "抠鼻", 79 | "[Face99]", "鼓掌", 80 | 81 | "[Face100]", "糗大了", 82 | "[Face101]", "坏笑", 83 | "[Face102]", "左哼哼", 84 | "[Face103]", "右哼哼", 85 | "[Face104]", "哈欠", 86 | "[Face105]", "鄙视", 87 | "[Face106]", "委屈", 88 | "[Face107]", "快哭了", 89 | "[Face108]", "阴险", 90 | "[Face109]", "左亲亲", 91 | "[Face110]", "吓", 92 | "[Face111]", "可怜", 93 | "[Face112]", "菜刀", 94 | "[Face113]", "啤酒", 95 | "[Face114]", "篮球", 96 | "[Face115]", "乒乓", 97 | "[Face116]", "示爱", 98 | "[Face117]", "瓢虫", 99 | "[Face118]", "抱拳", 100 | "[Face119]", "勾引", 101 | "[Face120]", "拳头", 102 | "[Face121]", "差劲", 103 | "[Face122]", "爱你", 104 | "[Face123]", "NO", 105 | "[Face124]", "OK", 106 | "[Face125]", "转圈", 107 | "[Face126]", "磕头", 108 | "[Face127]", "回头", 109 | "[Face128]", "跳绳", 110 | "[Face129]", "挥手", 111 | "[Face130]", "激动", 112 | "[Face131]", "街舞", 113 | "[Face132]", "献吻", 114 | "[Face133]", "左太极", 115 | "[Face134]", "右太极", 116 | "[Face136]", "双喜", 117 | "[Face137]", "鞭炮", 118 | "[Face138]", "灯笼", 119 | "[Face140]", "K歌", 120 | "[Face144]", "喝彩", 121 | "[Face145]", "祈祷", 122 | "[Face146]", "爆筋", 123 | "[Face147]", "棒棒糖", 124 | "[Face148]", "喝奶", 125 | "[Face151]", "飞机", 126 | "[Face158]", "钞票", 127 | "[Face168]", "药", 128 | "[Face169]", "手枪", 129 | "[Face171]", "茶", 130 | "[Face172]", "眨眼睛", 131 | "[Face173]", "泪奔", 132 | "[Face174]", "无奈", 133 | "[Face175]", "卖萌", 134 | "[Face176]", "小纠结", 135 | "[Face177]", "喷血", 136 | "[Face178]", "斜眼笑", 137 | "[Face179]", "doge", 138 | "[Face180]", "惊喜", 139 | "[Face181]", "骚扰", 140 | "[Face182]", "笑哭", 141 | "[Face183]", "我最美", 142 | "[Face184]", "河蟹", 143 | "[Face185]", "羊驼", 144 | "[Face187]", "幽灵", 145 | "[Face188]", "蛋", 146 | "[Face190]", "菊花", 147 | "[Face192]", "红包", 148 | "[Face193]", "大笑", 149 | "[Face194]", "不开心", 150 | "[Face197]", "冷漠", 151 | "[Face198]", "呃", 152 | "[Face199]", "好棒", 153 | 154 | "[Face200]", "拜托", 155 | "[Face201]", "点赞", 156 | "[Face202]", "无聊", 157 | "[Face203]", "托脸", 158 | "[Face204]", "吃", 159 | "[Face205]", "送花", 160 | "[Face206]", "害怕", 161 | "[Face207]", "花痴", 162 | "[Face208]", "小样儿", 163 | "[Face210]", "飙泪", 164 | "[Face211]", "我不看", 165 | "[Face212]", "托腮", 166 | "[Face214]", "啵啵", 167 | "[Face215]", "糊脸", 168 | "[Face216]", "拍头", 169 | "[Face217]", "扯一扯", 170 | "[Face218]", "舔一舔", 171 | "[Face219]", "蹭一蹭", 172 | "[Face220]", "拽炸天", 173 | "[Face221]", "顶呱呱", 174 | "[Face222]", "抱抱", 175 | "[Face223]", "暴击", 176 | "[Face224]", "开枪", 177 | "[Face225]", "撩一撩", 178 | "[Face226]", "拍桌", 179 | "[Face227]", "拍手", 180 | "[Face228]", "恭喜", 181 | "[Face229]", "干杯", 182 | "[Face230]", "嘲讽", 183 | "[Face231]", "哼", 184 | "[Face232]", "佛系", 185 | "[Face233]", "掐一掐", 186 | "[Face234]", "惊呆", 187 | "[Face235]", "颤抖", 188 | "[Face236]", "啃头", 189 | "[Face237]", "偷看", 190 | "[Face238]", "扇脸", 191 | "[Face239]", "原谅", 192 | "[Face240]", "喷脸", 193 | "[Face241]", "生日快乐", 194 | "[Face242]", "头撞击", 195 | "[Face243]", "甩头", 196 | "[Face244]", "扔狗", 197 | "[Face245]", "加油必胜", 198 | "[Face246]", "加油抱抱", 199 | "[Face247]", "口罩护体", 200 | "[Face260]", "搬砖中", 201 | "[Face261]", "忙到飞起", 202 | "[Face262]", "脑阔疼", 203 | "[Face263]", "沧桑", 204 | "[Face264]", "捂脸", 205 | "[Face265]", "辣眼睛", 206 | "[Face266]", "哦哟", 207 | "[Face267]", "头秃", 208 | "[Face268]", "问号脸", 209 | "[Face269]", "暗中观察", 210 | "[Face270]", "emm", 211 | "[Face271]", "吃瓜", 212 | "[Face272]", "呵呵哒", 213 | "[Face273]", "我酸了", 214 | "[Face274]", "太南了", 215 | "[Face276]", "辣椒酱", 216 | "[Face277]", "汪汪", 217 | "[Face278]", "汗", 218 | "[Face279]", "打脸", 219 | "[Face280]", "击掌", 220 | "[Face281]", "无眼笑", 221 | "[Face282]", "敬礼", 222 | "[Face283]", "狂笑", 223 | "[Face284]", "面无表情", 224 | "[Face285]", "摸鱼", 225 | "[Face286]", "魔鬼笑", 226 | "[Face287]", "哦", 227 | "[Face288]", "请", 228 | "[Face289]", "睁眼", 229 | "[Face290]", "敲开心", 230 | "[Face291]", "震惊", 231 | "[Face292]", "让我康康", 232 | "[Face293]", "摸锦鲤", 233 | "[Face294]", "期待", 234 | "[Face295]", "拿到红包", 235 | "[Face296]", "真好", 236 | "[Face297]", "拜谢", 237 | "[Face298]", "元宝", 238 | "[Face299]", "牛啊", 239 | 240 | "[Face300]", "胖三斤", 241 | "[Face301]", "好闪", 242 | "[Face302]", "左拜年", 243 | "[Face303]", "右拜年", 244 | "[Face304]", "红包包", 245 | "[Face305]", "右亲亲", 246 | "[Face306]", "牛气冲天", 247 | "[Face307]", "喵喵", 248 | "[Face308]", "求红包", 249 | "[Face309]", "谢红包", 250 | "[Face310]", "新年烟花", 251 | "[Face311]", "打call", 252 | "[Face312]", "变形", 253 | "[Face313]", "嗑到了", 254 | "[Face314]", "仔细分析", 255 | "[Face315]", "加油", 256 | "[Face316]", "我没事", 257 | "[Face317]", "菜汪", 258 | "[Face318]", "崇拜", 259 | "[Face319]", "比心", 260 | "[Face320]", "庆祝", 261 | "[Face321]", "老色痞", 262 | "[Face322]", "拒绝", 263 | "[Face323]", "嫌弃", 264 | "[Face324]", "吃糖", 265 | "[Face325]", "惊吓", 266 | "[Face326]", "生气", 267 | "[Face327]", "加一", 268 | "[Face328]", "错号", 269 | "[Face329]", "对号", 270 | "[Face330]", "完成", 271 | "[Face331]", "明白", 272 | "[Face332]", "举牌牌", 273 | "[Face333]", "烟花", 274 | "[Face334]", "虎虎生威", 275 | "[Face336]", "豹富", 276 | "[Face337]", "花朵脸", 277 | "[Face338]", "我想开了", 278 | "[Face339]", "舔屏", 279 | "[Face340]", "热化了", 280 | "[Face341]", "打招呼", 281 | "[Face342]", "酸Q", 282 | "[Face343]", "我方了", 283 | "[Face344]", "大怨种", 284 | "[Face345]", "红包多多", 285 | "[Face346]", "你真棒棒", 286 | "[Face347]", "大展宏兔", 287 | "[Face348]", "福萝卜", 288 | ) 289 | 290 | var wechatReplacer = strings.NewReplacer( 291 | "[微笑]", "😃", "[Smile]", "😃", 292 | "[撇嘴]", "😖", "[Grimace]", "😖", 293 | "[色]", "😍", "[Drool]", "😍", 294 | "[发呆]", "😳", "[Scowl]", "😳", 295 | "[得意]", "😎", "[Chill]", "😎", 296 | "[流泪]", "😭", "[Sob]", "😭", 297 | "[害羞]", "☺️", "[Shy]", "☺️", 298 | "[闭嘴]", "🤐", "[Shutup]", "🤐", 299 | "[睡]", "😴", "[Sleep]", "😴", 300 | "[大哭]", "😣", "[Cry]", "😣", 301 | "[尴尬]", "😰", "[Awkward]", "😰", 302 | "[发怒]", "😡", "[Pout]", "😡", 303 | "[调皮]", "😜", "[Wink]", "😜", 304 | "[呲牙]", "😁", "[Grin]", "😁", 305 | "[惊讶]", "😱", "[Surprised]", "😱", 306 | "[难过]", "🙁", "[Frown]", "🙁", 307 | "[囧]", "☺️", "[Tension]", "☺️", 308 | "[抓狂]", "😫", "[Scream]", "😫", 309 | "[吐]", "🤢", "[Puke]", "🤢", 310 | "[偷笑]", "🙈", "[Chuckle]", "🙈", 311 | "[愉快]", "☺️", "[Joyful]", "☺️", 312 | "[白眼]", "🙄", "[Slight]", "🙄", 313 | "[傲慢]", "😕", "[Smug]", "😕", 314 | "[困]", "😪", "[Drowsy]", "😪", 315 | "[惊恐]", "😱", "[Panic]", "😱", 316 | "[流汗]", "😓", "[Sweat]", "😓", 317 | "[憨笑]", "😄", "[Laugh]", "😄", 318 | "[悠闲]", "😏", "[Loafer]", "😏", 319 | "[奋斗]", "💪", "[Strive]", "💪", 320 | "[咒骂]", "😤", "[Scold]", "😤", 321 | "[疑问]", "❓", "[Doubt]", "❓", 322 | "[嘘]", "🤐", "[Shhh]", "🤐", 323 | "[晕]", "😲", "[Dizzy]", "😲", 324 | "[衰]", "😳", "[BadLuck]", "😳", 325 | "[骷髅]", "💀", "[Skull]", "💀", 326 | "[敲打]", "👊", "[Hammer]", "👊", 327 | "[再见]", "🙋\u200d♂", "[Bye]", "🙋\u200d♂", 328 | "[擦汗]", "😥", "[Relief]", "😥", 329 | "[抠鼻]", "🤷\u200d♂", "[DigNose]", "🤷\u200d♂", 330 | "[鼓掌]", "👏", "[Clap]", "👏", 331 | "[坏笑]", "👻", "[Trick]", "👻", 332 | "[左哼哼]", "😾", "[Bah!L]", "😾", 333 | "[右哼哼]", "😾", "[Bah!R]", "😾", 334 | "[哈欠]", "😪", "[Yawn]", "😪", 335 | "[鄙视]", "😒", "[Lookdown]", "😒", 336 | "[委屈]", "😣", "[Wronged]", "😣", 337 | "[快哭了]", "😔", "[Puling]", "😔", 338 | "[阴险]", "😈", "[Sly]", "😈", 339 | "[亲亲]", "😘", "[Kiss]", "😘", 340 | "[可怜]", "😻", "[Whimper]", "😻", 341 | "[菜刀]", "🔪", "[Cleaver]", "🔪", 342 | "[西瓜]", "🍉", "[Melon]", "🍉", 343 | "[啤酒]", "🍺", "[Beer]", "🍺", 344 | "[咖啡]", "☕", "[Coffee]", "☕", 345 | "[猪头]", "🐷", "[Pig]", "🐷", 346 | "[玫瑰]", "🌹", "[Rose]", "🌹", 347 | "[凋谢]", "🥀", "[Wilt]", "🥀", 348 | "[嘴唇]", "💋", "[Lip]", "💋", 349 | "[爱心]", "❤️", "[Heart]", "❤️", 350 | "[心碎]", "💔", "[BrokenHeart]", "💔", 351 | "[蛋糕]", "🎂", "[Cake]", "🎂", 352 | "[炸弹]", "💣", "[Bomb]", "💣", 353 | "[便便]", "💩", "[Poop]", "💩", 354 | "[月亮]", "🌃", "[Moon]", "🌃", 355 | "[太阳]", "🌞", "[Sun]", "🌞", 356 | "[拥抱]", "🤗", "[Hug]", "🤗", 357 | "[强]", "👍", "[Strong]", "👍", 358 | "[弱]", "👎", "[Weak]", "👎", 359 | "[握手]", "🤝", "[Shake]", "🤝", 360 | "[胜利]", "✌️", "[Victory]", "✌️", 361 | "[抱拳]", "🙏", "[Salute]", "🙏", 362 | "[勾引]", "💁\u200d♂", "[Beckon]", "💁\u200d♂", 363 | "[拳头]", "👊", "[Fist]", "👊", 364 | "[OK]", "👌", 365 | "[跳跳]", "💃", "[Waddle]", "💃", 366 | "[发抖]", "🙇", "[Tremble]", "🙇", 367 | "[怄火]", "😡", "[Aaagh!]", "😡", 368 | "[转圈]", "🕺", "[Twirl]", "🕺", 369 | "[嘿哈]", "🤣", "[Hey]", "🤣", 370 | "[捂脸]", "🤦\u200d♂", "[Facepalm]", "🤦\u200d♂", 371 | "[奸笑]", "😜", "[Smirk]", "😜", 372 | "[机智]", "🤓", "[Smart]", "🤓", 373 | "[皱眉]", "😟", "[Concerned]", "😟", 374 | "[耶]", "✌️", "[Yeah!]", "✌️", 375 | "[红包]", "🧧", "[Packet]", "🧧", 376 | "[鸡]", "🐥", "[Chick]", "🐥", 377 | "[蜡烛]", "🕯️", "[Candle]", "🕯️", 378 | "[糗大了]", "😥", 379 | "[ThumbsUp]", "👍", "[ThumbsDown]", "👎", 380 | "[Peace]", "✌️", 381 | "[Pleased]", "😊", 382 | "[Rich]", "🀅", 383 | "[Pup]", "🐶", 384 | "[吃瓜]", "🙄\u200d🍉", "[Onlooker]", "🙄\u200d🍉", 385 | "[加油]", "💪\u200d😁", "[GoForIt]", "💪\u200d😁", 386 | "[加油加油]", "💪\u200d😷", 387 | "[汗]", "😓", "[Sweats]", "😓", 388 | "[天啊]", "😱", "[OMG]", "😱", 389 | "[Emm]", "🤔", 390 | "[社会社会]", "😏", "[Respect]", "😏", 391 | "[旺柴]", "🐶\u200d😏", "[Doge]", "🐶\u200d😏", 392 | "[好的]", "😏\u200d👌", "[NoProb]", "😏\u200d👌", 393 | "[哇]", "🤩", "[Wow]", "🤩", 394 | "[打脸]", "😟\u200d🤚", "[MyBad]", "😟\u200d🤚", 395 | "[破涕为笑]", "😂", "[破涕為笑]", "😂", "[Lol]", "😂", 396 | "[苦涩]", "😭", "[Hurt]", "😭", 397 | "[翻白眼]", "🙄", "[Boring]", "🙄", 398 | "[裂开]", "🫠", "[Broken]", "🫠", 399 | "[爆竹]", "🧨", "[Firecracker]", "🧨", 400 | "[烟花]", "🎆", "[Fireworks]", "🎆", 401 | "[福]", "🧧", "[Blessing]", "🧧", 402 | "[礼物]", "🎁", "[Gift]", "🎁", 403 | "[庆祝]", "🎉", "[Party]", "🎉", 404 | "[合十]", "🙏", "[Worship]", "🙏", 405 | "[叹气]", "😮‍💨", "[Sigh]", "😮‍💨", 406 | "[让我看看]", "👀", "[LetMeSee]", "👀", 407 | "[666]", "6️⃣6️⃣6️⃣", 408 | "[无语]", "😑", "[Duh]", "😑", 409 | "[失望]", "😞", "[Let Down]", "😞", 410 | "[恐惧]", "😨", "[Terror]", "😨", 411 | "[脸红]", "😳", "[Flushed]", "😳", 412 | "[生病]", "😷", "[Sick]", "😷", 413 | "[笑脸]", "😁", "[Happy]", "😁", 414 | ) 415 | 416 | type EmoticonS2MFilter struct { 417 | } 418 | 419 | // QQ/WeChat -> Telegram: replace QQ/WeChat emoticon 420 | func (f EmoticonS2MFilter) Apply(event *common.OctopusEvent) *common.OctopusEvent { 421 | switch event.Vendor.Type { 422 | case "qq": 423 | event.Content = qqReplacer.Replace(event.Content) 424 | case "wechat": 425 | event.Content = wechatReplacer.Replace(event.Content) 426 | } 427 | 428 | return event 429 | } 430 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Benau/go_rlottie v0.0.0-20210807002906-98c1b2421989 h1:+wrfJITuBoQOE6ST4k3c4EortNVQXVhfAbwt0M/j0+Y= 2 | github.com/Benau/go_rlottie v0.0.0-20210807002906-98c1b2421989/go.mod h1:aDWSWjsayFyGTvHZH3v4ijGXEBe51xcEkAK+NUWeOeo= 3 | github.com/Benau/tgsconverter v0.0.0-20210809170556-99f4a4f6337f h1:aUkwZDEMJIGRcWlSDifSLoKG37UCOH/DPeG52/xwois= 4 | github.com/Benau/tgsconverter v0.0.0-20210809170556-99f4a4f6337f/go.mod h1:AQiQKKI/YIIctvDt3hI3c1S05/JXMM7v/sQcRd0paVE= 5 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.28 h1:3EidAXUUuDBwaRX5881fmpGGv2WPnW9oHwRMlvdQiwU= 6 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.28/go.mod h1:kL1v4iIjlalwm3gCYGvF4NLa3hs+aKEfRkNJvj4aoDU= 7 | github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= 8 | github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= 9 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 10 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 11 | github.com/av-elier/go-decimal-to-rational v0.0.0-20191127152832-89e6aad02ecf h1:csfEAyvOG4/498Q4SyF48ysFqQC9ESj3o8ppRtg+Rog= 12 | github.com/av-elier/go-decimal-to-rational v0.0.0-20191127152832-89e6aad02ecf/go.mod h1:POPnOeaYF7U9o3PjLTb9icRfEOxjBNLRXh9BLximJGM= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 17 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 18 | github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= 19 | github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= 20 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 21 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 22 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 23 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 24 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 25 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 26 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 27 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 28 | github.com/kettek/apng v0.0.0-20191108220231-414630eed80f/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q= 29 | github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 h1:8tP9cdXzcGX2AvweVVG/lxbI7BSjWbNNUustwJ9dQVA= 30 | github.com/kettek/apng v0.0.0-20220823221153-ff692776a607/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q= 31 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 32 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 33 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 34 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 35 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 36 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 40 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 41 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 42 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 43 | github.com/sizeofint/webpanimation v0.0.0-20210809145948-1d2b32119882 h1:A7o8tOERTtpD/poS+2VoassCjXpjHn916luXbf5QKD0= 44 | github.com/sizeofint/webpanimation v0.0.0-20210809145948-1d2b32119882/go.mod h1:5IwJoz9Pw7JsrCN4/skkxUtSWT7myuUPLhCgv6Q5vvQ= 45 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 46 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 47 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 48 | github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= 49 | github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 50 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 51 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 52 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 53 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 54 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 55 | github.com/youthlin/silk v0.0.4 h1:BhE/7QHvlOj0QQS7CIyGuNyWnTWqTaeq5v7jJ8/asWU= 56 | github.com/youthlin/silk v0.0.4/go.mod h1:5rsiA9UPJoXecMP3KLz6MAZGBNAPzIE13id8isu49/U= 57 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 58 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 59 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 60 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= 61 | golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 62 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 63 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 64 | golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= 65 | golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 66 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 67 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 68 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 69 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 70 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 71 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= 72 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 73 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 74 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 75 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 76 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 77 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 78 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 79 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 80 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 88 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 89 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 90 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 91 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 92 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 93 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 94 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 95 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 96 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 97 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 98 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 99 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 100 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 101 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 102 | golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= 103 | golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 104 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 105 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 106 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 107 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 108 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 109 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 110 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 111 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 112 | modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= 113 | modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= 114 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 115 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 116 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= 117 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 118 | modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8= 119 | modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= 120 | modernc.org/libc v1.54.4 h1:eDr4WnANZv+aRBKNCDo4khJbaHpxoTNOxeXqpznSZyY= 121 | modernc.org/libc v1.54.4/go.mod h1:CH8KSvv67UxcGCOLizggw3Zi3yT+sUjLWysK/YeUnqk= 122 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 123 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 124 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 125 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 126 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 127 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 128 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 129 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 130 | modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk= 131 | modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU= 132 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 133 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 134 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 135 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 136 | -------------------------------------------------------------------------------- /internal/master/command.go: -------------------------------------------------------------------------------- 1 | package master 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "golang.org/x/exp/slices" 8 | 9 | "github.com/duo/octopus/internal/common" 10 | "github.com/duo/octopus/internal/manager" 11 | 12 | "github.com/PaulSonOfLars/gotgbot/v2" 13 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 14 | 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const ( 19 | maxShowBindedLinks = 7 20 | ) 21 | 22 | func onCommand(bot *gotgbot.Bot, ctx *ext.Context, config *common.Configure) error { 23 | text := ctx.EffectiveMessage.Text 24 | if strings.HasPrefix(text, "/help") { 25 | _, err := bot.SendMessage( 26 | ctx.EffectiveChat.Id, 27 | "help - Show command list.\nlink - Manage remote chat link.\nchat - Generate a remote chat head.", 28 | nil, 29 | ) 30 | return err 31 | } else if strings.HasPrefix(text, "/link") { 32 | if ctx.EffectiveChat.IsForum && ctx.EffectiveMessage.MessageThreadId != 0 { 33 | _, err := bot.SendMessage( 34 | ctx.EffectiveChat.Id, 35 | "Link in topic not support.", 36 | &gotgbot.SendMessageOpts{ 37 | MessageThreadId: ctx.EffectiveMessage.MessageThreadId, 38 | }, 39 | ) 40 | return err 41 | } else if ctx.EffectiveChat.Type == "private" { 42 | _, err := bot.SendMessage(ctx.EffectiveChat.Id, "Link in private chat does not support.", nil) 43 | return err 44 | } 45 | 46 | cb := Callback{ 47 | Category: "link", 48 | Acction: "list", 49 | } 50 | parts := strings.Split(text, " ") 51 | if len(parts) == 2 { 52 | cb.Query = parts[1] 53 | } 54 | 55 | return handleLink(bot, ctx, config, ctx.Message.From.Id, cb) 56 | } else if strings.HasPrefix(text, "/chat") { 57 | cb := Callback{ 58 | Category: "chat", 59 | Acction: "list", 60 | } 61 | parts := strings.Split(text, " ") 62 | if len(parts) == 2 { 63 | cb.Query = parts[1] 64 | } 65 | 66 | return handleChat(bot, ctx, config, ctx.Message.From.Id, cb) 67 | } else { 68 | _, err := bot.SendMessage( 69 | ctx.EffectiveChat.Id, 70 | "Command not support.", 71 | &gotgbot.SendMessageOpts{ 72 | MessageThreadId: ctx.EffectiveMessage.MessageThreadId, 73 | }, 74 | ) 75 | return err 76 | } 77 | } 78 | 79 | func handleLink(bot *gotgbot.Bot, ctx *ext.Context, config *common.Configure, userID int64, cb Callback) error { 80 | if cb.Acction == "close" { 81 | _, _, err := ctx.EffectiveMessage.EditText( 82 | bot, 83 | "_Canceled by user._", 84 | &gotgbot.EditMessageTextOpts{ParseMode: "Markdown"}, 85 | ) 86 | return err 87 | } else if cb.Acction == "bind" { 88 | masterLimb := common.Limb{ 89 | Type: "telegram", 90 | UID: common.Itoa(userID), 91 | ChatID: common.Itoa(ctx.EffectiveChat.Id), 92 | }.String() 93 | 94 | if err := manager.AddLink(&manager.Link{ 95 | MasterLimb: masterLimb, 96 | SlaveLimb: cb.Data, 97 | }); err != nil { 98 | log.Warnf("Add link failed: %v", err) 99 | } 100 | } else if cb.Acction == "unbind" { 101 | if id, err := common.Atoi(cb.Data); err != nil { 102 | log.Warnf("Parse callback data failed: %v", err) 103 | } else if err := manager.DelLinkById(id); err != nil { 104 | log.Warnf("Delete link failed: %v", err) 105 | } 106 | } 107 | 108 | return showLinks(bot, ctx, config, userID, cb) 109 | } 110 | 111 | func handleChat(bot *gotgbot.Bot, ctx *ext.Context, config *common.Configure, userID int64, cb Callback) error { 112 | if cb.Acction == "close" { 113 | _, _, err := ctx.EffectiveMessage.EditText( 114 | bot, 115 | "_Canceled by user._", 116 | &gotgbot.EditMessageTextOpts{ParseMode: "Markdown"}, 117 | ) 118 | return err 119 | } else if cb.Acction == "talk" { 120 | chat, err := manager.GetChat(cb.Data) 121 | if err != nil { 122 | log.Warnf("Get chat failed: %v", err) 123 | return err 124 | } 125 | 126 | masterLimb := common.Limb{ 127 | Type: "telegram", 128 | UID: common.Itoa(userID), 129 | ChatID: common.Itoa(ctx.EffectiveChat.Id), 130 | }.String() 131 | 132 | if err := manager.AddMessage(&manager.Message{ 133 | MasterLimb: masterLimb, 134 | MasterMsgID: common.Itoa(ctx.EffectiveMessage.MessageId), 135 | MasterMsgThreadID: common.Itoa(ctx.EffectiveMessage.MessageThreadId), 136 | SlaveLimb: chat.Limb, 137 | SlaveMsgID: "0", 138 | }); err != nil { 139 | log.Warnf("Add message failed: %v", err) 140 | return err 141 | } 142 | 143 | _, _, err = ctx.EffectiveMessage.EditText( 144 | bot, 145 | fmt.Sprintf( 146 | "*Reply this message to talk with %s*", 147 | common.EscapeText("Markdown", chat.Title), 148 | ), 149 | &gotgbot.EditMessageTextOpts{ParseMode: "Markdown"}, 150 | ) 151 | return err 152 | } 153 | 154 | return showChats(bot, ctx, config, cb) 155 | } 156 | 157 | func showLinks(bot *gotgbot.Bot, ctx *ext.Context, config *common.Configure, userID int64, cb Callback) error { 158 | masterLimb := common.Limb{ 159 | Type: "telegram", 160 | UID: common.Itoa(userID), 161 | ChatID: common.Itoa(ctx.EffectiveChat.Id), 162 | }.String() 163 | 164 | count, err := manager.GetChatCount(cb.Query) 165 | if err != nil { 166 | log.Warnf("Get chat cout failed: %v", err) 167 | return err 168 | } 169 | 170 | pager := manager.CalcPager(cb.Page, config.Master.PageSize, count) 171 | 172 | links, err := manager.GetLinkList() 173 | if err != nil { 174 | log.Warnf("Get link list failed: %v", err) 175 | return err 176 | } 177 | 178 | chats, err := manager.GetChatList(pager.CurrentPage, config.Master.PageSize, cb.Query) 179 | if err != nil { 180 | log.Warnf("Get chat list failed: %v", err) 181 | return err 182 | } 183 | 184 | if len(chats) == 0 { 185 | _, err := bot.SendMessage( 186 | ctx.EffectiveChat.Id, 187 | "No chat currently avaiable.", 188 | &gotgbot.SendMessageOpts{ 189 | MessageThreadId: ctx.EffectiveMessage.MessageThreadId, 190 | }, 191 | ) 192 | return err 193 | } 194 | 195 | text := "Links:" 196 | 197 | bindLinks, err := manager.GetLinksByMaster(masterLimb) 198 | if err != nil { 199 | log.Warnf("Get links by master failed: %v", err) 200 | return err 201 | } 202 | for idx, l := range bindLinks { 203 | if idx >= maxShowBindedLinks { 204 | break 205 | } 206 | limb, _ := common.LimbFromString(l.SlaveLimb) 207 | text += fmt.Sprintf("\n🔗%s(%s) from (%s %s)", l.Title, limb.ChatID, limb.Type, limb.UID) 208 | } 209 | if len(bindLinks) > maxShowBindedLinks { 210 | text += fmt.Sprintf("\n\nand %d more...", len(bindLinks)-maxShowBindedLinks) 211 | } 212 | 213 | keyboard := [][]gotgbot.InlineKeyboardButton{} 214 | for _, chat := range chats { 215 | limb, _ := common.LimbFromString(chat.Limb) 216 | info := fmt.Sprintf("%s(%s) from (%s %s)", chat.Title, limb.ChatID, limb.Type, limb.UID) 217 | if chat.ChatType == "private" { 218 | info = "👤" + info 219 | } else { 220 | info = "👥" + info 221 | } 222 | 223 | cb := Callback{ 224 | Category: "link", 225 | 226 | Query: cb.Query, 227 | Page: pager.CurrentPage, 228 | } 229 | 230 | idx := slices.IndexFunc(links, func(l *manager.Link) bool { 231 | return l.MasterLimb == masterLimb && l.SlaveLimb == chat.Limb 232 | }) 233 | 234 | if idx == -1 { 235 | cb.Acction = "bind" 236 | cb.Data = chat.Limb 237 | } else { 238 | info = "🔗" + info 239 | 240 | cb.Acction = "unbind" 241 | cb.Data = common.Itoa(links[idx].ID) 242 | } 243 | 244 | btn := gotgbot.InlineKeyboardButton{Text: info, CallbackData: putCallback(cb)} 245 | keyboard = append(keyboard, []gotgbot.InlineKeyboardButton{btn}) 246 | } 247 | 248 | var bottom = []gotgbot.InlineKeyboardButton{} 249 | if pager.HasPrev { 250 | cb := Callback{ 251 | Category: "link", 252 | Acction: "list", 253 | Query: cb.Query, 254 | Page: pager.PrevPage, 255 | } 256 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: "< Prev", CallbackData: putCallback(cb)}) 257 | } else { 258 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: " ", CallbackData: "0"}) 259 | } 260 | { 261 | info := fmt.Sprintf("%d / %d (%d) | Cancel", 262 | pager.CurrentPage, pager.NumPages, pager.NumItems) 263 | cb := Callback{ 264 | Category: "link", 265 | Acction: "close", 266 | } 267 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: info, CallbackData: putCallback(cb)}) 268 | } 269 | if pager.HasNext { 270 | cb := Callback{ 271 | Category: "link", 272 | Acction: "list", 273 | Query: cb.Query, 274 | Page: pager.NextPage, 275 | } 276 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: "Next >", CallbackData: putCallback(cb)}) 277 | } else { 278 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: " ", CallbackData: "0"}) 279 | } 280 | keyboard = append(keyboard, bottom) 281 | 282 | if ctx.EffectiveMessage.From.Id == bot.User.Id { 283 | _, _, err := ctx.EffectiveMessage.EditText( 284 | bot, 285 | text, 286 | &gotgbot.EditMessageTextOpts{ 287 | ReplyMarkup: gotgbot.InlineKeyboardMarkup{ 288 | InlineKeyboard: keyboard, 289 | }, 290 | }, 291 | ) 292 | return err 293 | } else { 294 | _, err := bot.SendMessage( 295 | ctx.EffectiveChat.Id, 296 | text, 297 | &gotgbot.SendMessageOpts{ 298 | MessageThreadId: ctx.EffectiveMessage.MessageThreadId, 299 | ReplyMarkup: gotgbot.InlineKeyboardMarkup{ 300 | InlineKeyboard: keyboard, 301 | }, 302 | }, 303 | ) 304 | return err 305 | } 306 | } 307 | 308 | func showChats(bot *gotgbot.Bot, ctx *ext.Context, config *common.Configure, cb Callback) error { 309 | count, err := manager.GetChatCount(cb.Query) 310 | if err != nil { 311 | log.Warnf("Get chat cout failed: %v", err) 312 | return err 313 | } 314 | 315 | pager := manager.CalcPager(cb.Page, config.Master.PageSize, count) 316 | 317 | chats, err := manager.GetChatList(pager.CurrentPage, config.Master.PageSize, cb.Query) 318 | if err != nil { 319 | log.Warnf("Get chat list failed: %v", err) 320 | return err 321 | } 322 | 323 | if len(chats) == 0 { 324 | _, err := bot.SendMessage( 325 | ctx.EffectiveChat.Id, 326 | "No chat currently avaiable.", 327 | &gotgbot.SendMessageOpts{ 328 | MessageThreadId: ctx.EffectiveMessage.MessageThreadId, 329 | }, 330 | ) 331 | return err 332 | } 333 | 334 | keyboard := [][]gotgbot.InlineKeyboardButton{} 335 | for _, chat := range chats { 336 | limb, _ := common.LimbFromString(chat.Limb) 337 | info := fmt.Sprintf("%s(%s) from (%s %s)", chat.Title, limb.ChatID, limb.Type, limb.UID) 338 | if chat.ChatType == "private" { 339 | info = "👤" + info 340 | } else { 341 | info = "👥" + info 342 | } 343 | 344 | cb := Callback{ 345 | Category: "chat", 346 | Acction: "talk", 347 | Data: chat.Limb, 348 | Query: cb.Query, 349 | Page: pager.CurrentPage, 350 | } 351 | 352 | btn := gotgbot.InlineKeyboardButton{Text: info, CallbackData: putCallback(cb)} 353 | keyboard = append(keyboard, []gotgbot.InlineKeyboardButton{btn}) 354 | } 355 | 356 | var bottom = []gotgbot.InlineKeyboardButton{} 357 | if pager.HasPrev { 358 | cb := Callback{ 359 | Category: "chat", 360 | Acction: "list", 361 | Query: cb.Query, 362 | Page: pager.PrevPage, 363 | } 364 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: "< Prev", CallbackData: putCallback(cb)}) 365 | } else { 366 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: " ", CallbackData: "0"}) 367 | } 368 | { 369 | info := fmt.Sprintf("%d / %d (%d) | Cancel", 370 | pager.CurrentPage, pager.NumPages, pager.NumItems) 371 | cb := Callback{ 372 | Category: "chat", 373 | Acction: "close", 374 | } 375 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: info, CallbackData: putCallback(cb)}) 376 | } 377 | if pager.HasNext { 378 | cb := Callback{ 379 | Category: "chat", 380 | Acction: "list", 381 | Query: cb.Query, 382 | Page: pager.NextPage, 383 | } 384 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: "Next >", CallbackData: putCallback(cb)}) 385 | } else { 386 | bottom = append(bottom, gotgbot.InlineKeyboardButton{Text: " ", CallbackData: "0"}) 387 | } 388 | keyboard = append(keyboard, bottom) 389 | 390 | if ctx.EffectiveMessage.From.Id == bot.User.Id { 391 | _, _, err := ctx.EffectiveMessage.EditReplyMarkup( 392 | bot, 393 | &gotgbot.EditMessageReplyMarkupOpts{ 394 | ReplyMarkup: gotgbot.InlineKeyboardMarkup{ 395 | InlineKeyboard: keyboard, 396 | }, 397 | }, 398 | ) 399 | return err 400 | } else { 401 | _, err := bot.SendMessage( 402 | ctx.EffectiveChat.Id, 403 | "Please choose a chat you'd like to talk.", 404 | &gotgbot.SendMessageOpts{ 405 | MessageThreadId: ctx.EffectiveMessage.MessageThreadId, 406 | ReplyMarkup: gotgbot.InlineKeyboardMarkup{ 407 | InlineKeyboard: keyboard, 408 | }, 409 | }, 410 | ) 411 | return err 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /internal/onebot/protocol.go: -------------------------------------------------------------------------------- 1 | package onebot 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/mitchellh/mapstructure" 9 | ) 10 | 11 | type PayloadType string 12 | 13 | const ( 14 | PaylaodRequest PayloadType = "request" 15 | PayloadResponse PayloadType = "response" 16 | PayloadEvent PayloadType = "event" 17 | ) 18 | 19 | type Payload interface { 20 | PayloadType() PayloadType 21 | } 22 | 23 | type RequestType string 24 | 25 | const ( 26 | SendPrivateMsg RequestType = "send_private_msg" 27 | SendGroupMsg RequestType = "send_group_msg" 28 | SendMsg RequestType = "send_msg" 29 | DeleteMsg RequestType = "delete_msg" 30 | GetMsg RequestType = "get_msg" 31 | GetForwardMsg RequestType = "get_forward_msg" 32 | SendLike RequestType = "send_like" 33 | SetGroupKick RequestType = "set_group_kick" 34 | SetGroupBan RequestType = "set_group_ban" 35 | SetGroupAnonymousBan RequestType = "set_group_anonymous_ban" 36 | SetGroupWholeBan RequestType = "set_group_whole_ban" 37 | SetGroupAdmin RequestType = "set_group_admin" 38 | SetGroupAnonymous RequestType = "set_group_anonymous" 39 | SetGroupCard RequestType = "set_group_card" 40 | SetGroupName RequestType = "set_group_name" 41 | SetGroupLeave RequestType = "set_group_leave" 42 | SetGroupSpecialTitle RequestType = "set_group_special_title" 43 | SetFriendAddRequest RequestType = "set_friend_add_request" 44 | SetGroupAddRequest RequestType = "set_group_add_request" 45 | GetLoginInfo RequestType = "get_login_info" 46 | GetStrangerInfo RequestType = "get_stranger_info" 47 | GetFriendList RequestType = "get_friend_list" 48 | GetGroupInfo RequestType = "get_group_info" 49 | GetGroupList RequestType = "get_group_list" 50 | GetGroupMemberInfo RequestType = "get_group_member_info" 51 | GetGroupMemberList RequestType = "get_group_member_list" 52 | GetGroupHonorInfo RequestType = "get_group_honor_info" 53 | GetCookies RequestType = "get_cookies" 54 | GetCSRFToken RequestType = "get_csrf_token" 55 | GetCredentials RequestType = "get_credentials" 56 | GetRecord RequestType = "get_record" 57 | GetImage RequestType = "get_image" 58 | CanSendImage RequestType = "can_send_image" 59 | CanSendRecord RequestType = "can_send_record" 60 | GetStatus RequestType = "get_status" 61 | GetVersionInfo RequestType = "get_version_info" 62 | SetRestart RequestType = "set_restart" 63 | CleanCache RequestType = "clean_cache" 64 | 65 | SendForwardMsg RequestType = "send_forward_msg" 66 | SendPrivateForwardMsg RequestType = "send_private_forward_msg" 67 | SendGroupForwardMsg RequestType = "send_group_forward_msg" 68 | UploadGroupFile RequestType = "upload_group_file" 69 | DownloadFile RequestType = "download_file" 70 | GetFile RequestType = "get_file" 71 | ) 72 | 73 | type Request struct { 74 | Action string `json:"action"` 75 | Params map[string]interface{} `json:"params,omitempty"` 76 | Echo string `json:"echo,omitempty"` 77 | } 78 | 79 | func (r *Request) PayloadType() PayloadType { 80 | return PaylaodRequest 81 | } 82 | 83 | func NewGetLoginInfoRequest() *Request { 84 | return &Request{Action: "get_login_info"} 85 | } 86 | 87 | func NewGetFriendListRequest() *Request { 88 | return &Request{Action: "get_friend_list"} 89 | } 90 | 91 | func NewGetGroupListRequest() *Request { 92 | return &Request{Action: "get_group_list"} 93 | } 94 | 95 | func NewGetGroupMemberInfoRequest(groupID, userID int64, noCache bool) *Request { 96 | return &Request{ 97 | Action: "get_group_member_info", 98 | Params: map[string]interface{}{ 99 | "group_id": groupID, 100 | "user_id": userID, 101 | }, 102 | } 103 | } 104 | 105 | func NewGetRecordRequest(file string) *Request { 106 | return &Request{ 107 | Action: "get_record", 108 | Params: map[string]interface{}{ 109 | "file": file, 110 | "out_format": "amr", 111 | }, 112 | } 113 | } 114 | 115 | func NewGetImageRequest(fileID string) *Request { 116 | return &Request{ 117 | Action: "get_image", 118 | Params: map[string]interface{}{"file": fileID}, 119 | } 120 | } 121 | 122 | func NewGetFileRequest(fileID string) *Request { 123 | return &Request{ 124 | Action: "get_file", 125 | Params: map[string]interface{}{"file_id": fileID}, 126 | } 127 | } 128 | 129 | func NewGetMsgRequest(id int32) *Request { 130 | return &Request{ 131 | Action: "get_msg", 132 | Params: map[string]interface{}{"message_id": id}, 133 | } 134 | } 135 | 136 | func NewGetForwardMsgRequest(id string) *Request { 137 | return &Request{ 138 | Action: "get_forward_msg", 139 | Params: map[string]interface{}{"id": id, "message_id": id}, 140 | } 141 | } 142 | 143 | func NewPrivateMsgRequest(userID int64, segments []ISegment) *Request { 144 | return &Request{ 145 | Action: "send_msg", 146 | Params: map[string]interface{}{ 147 | "message_type": "private", 148 | "user_id": userID, 149 | "message": segments, 150 | }, 151 | } 152 | } 153 | 154 | func NewGroupMsgRequest(groupID int64, segments []ISegment) *Request { 155 | return &Request{ 156 | Action: "send_msg", 157 | Params: map[string]interface{}{ 158 | "message_type": "group", 159 | "group_id": groupID, 160 | "message": segments, 161 | }, 162 | } 163 | } 164 | 165 | func NewPrivateForwardRequest(userID int64, messageID int32) *Request { 166 | return &Request{ 167 | Action: "forward_friend_single_msg", 168 | Params: map[string]interface{}{ 169 | "user_id": userID, 170 | "message_id": messageID, 171 | }, 172 | } 173 | } 174 | 175 | func NewGroupForwardRequest(groupID int64, messageID int32) *Request { 176 | return &Request{ 177 | Action: "forward_group_single_msg", 178 | Params: map[string]interface{}{ 179 | "group_id": groupID, 180 | "message_id": messageID, 181 | }, 182 | } 183 | } 184 | 185 | // Lagrange.OneBot 186 | func NewUploadPrivateFileRequest(userID int64, file string, name string) *Request { 187 | return &Request{ 188 | Action: "upload_private_file", 189 | Params: map[string]interface{}{ 190 | "user_id": userID, 191 | "file": file, 192 | "name": name, 193 | }, 194 | } 195 | } 196 | 197 | // Lagrange.OneBot 198 | func NewUploadGroupFileRequest(groupID int64, file string, name string) *Request { 199 | return &Request{ 200 | Action: "upload_group_file", 201 | Params: map[string]interface{}{ 202 | "group_id": groupID, 203 | "file": file, 204 | "name": name, 205 | }, 206 | } 207 | } 208 | 209 | type Response struct { 210 | Status string `json:"status"` 211 | Retcode int32 `json:"retcode"` 212 | Data any `json:"params,omitempty"` 213 | Echo string `json:"echo,omitempty"` 214 | } 215 | 216 | func (r *Response) PayloadType() PayloadType { 217 | return PayloadResponse 218 | } 219 | 220 | type FriendInfo struct { 221 | ID int64 `json:"user_id" mapstructure:"user_id"` 222 | Nickname string `json:"nickname,omitempty" mapstructure:"nickname,omitempty"` 223 | Remark string `json:"remark,omitempty" mapstructure:"remark,omitempty"` 224 | } 225 | 226 | type GroupInfo struct { 227 | ID int64 `json:"group_id" mapstructure:"group_id"` 228 | Name string `json:"group_name,omitempty" mapstructure:"group_name,omitempty"` 229 | } 230 | 231 | type FileInfo struct { 232 | ID string `json:"id,omitempty" mapstructure:"id,omitempty"` 233 | Name string `json:"name,omitempty" mapstructure:"name,omitempty"` 234 | File string `json:"file,omitempty" mapstructure:"file,omitempty"` 235 | FileName string `json:"file_name,omitempty" mapstructure:"file_name,omitempty"` 236 | URL string `json:"url" mapstructure:"url"` 237 | Base64 string `json:"base64,omitempty" mapstructure:"base64,omitempty"` 238 | Data []byte 239 | //FileSize string `json:"file_size" mapstructure:"file_size"` 240 | } 241 | 242 | type BareMessage struct { 243 | Time int32 `json:"time" mapstructure:"time"` 244 | MessageType string `json:"message_type" mapstructure:"message_type"` 245 | MessageID int32 `json:"message_id" mapstructure:"message_id"` 246 | RealID int32 `json:"real_id" mapstructure:"real_id"` 247 | Sender Sender `json:"sender" mapstructure:"sender"` 248 | Message any `json:"message" mapstructure:"message"` 249 | } 250 | 251 | type EventType string 252 | 253 | const ( 254 | MessagePrivate EventType = "message_private" 255 | MessageGroup EventType = "message_group" 256 | MetaLifecycle EventType = "meta_lifecycle" 257 | MetaHeartbeat EventType = "meta_heartbeat" 258 | NoticeOfflineFile EventType = "notice_offline_file" 259 | NoticeGroupUpload EventType = "notice_group_upload" 260 | NoticeGroupAdmin EventType = "notice_group_admin" 261 | NoticeGroupDecrease EventType = "notice_group_decrease" 262 | NoticeGroupIncrease EventType = "notice_group_increase" 263 | NoticeGroupBan EventType = "notice_group_ban" 264 | NoticeFriendAdd EventType = "notice_friend_add" 265 | NoticeGroupRecall EventType = "notice_group_recall" 266 | NoticeFriendRecall EventType = "notice_friend_recall" 267 | NoticeNotify EventType = "notice_notify" 268 | NoticeLuckyKing EventType = "notice_lucky_king" 269 | NoticeHonnor EventType = "notice_honnor" 270 | RequestFriend EventType = "request_friend" 271 | RequestGroup EventType = "request_group" 272 | EventUnsupport EventType = "event_unsupport" 273 | ) 274 | 275 | type IEvent interface { 276 | EventType() EventType 277 | } 278 | 279 | type Event struct { 280 | Time int64 `json:"time" mapstructure:"time"` 281 | SelfID int64 `json:"self_id" mapstructure:"self_id"` 282 | PostType string `json:"post_type" mapstructure:"post_type"` 283 | } 284 | 285 | func (e *Event) PayloadType() PayloadType { 286 | return PayloadEvent 287 | } 288 | 289 | func (e *Event) EventType() EventType { 290 | return EventUnsupport 291 | } 292 | 293 | type Message struct { 294 | Event `mapstructure:",squash"` 295 | MessageType string `json:"message_type" mapstructure:"message_type"` 296 | SubType string `json:"sub_type" mapstructure:"sub_type"` 297 | MessageID int32 `json:"message_id" mapstructure:"message_id"` 298 | GroupID int64 `json:"group_id,omitempty" mapstructure:"group_id,omitempty"` 299 | UserID int64 `json:"user_id" mapstructure:"user_id"` 300 | TargetID int64 `json:"target_id,omitempty" mapstructure:"target_id,omitempty"` 301 | Anonymous Anonymous `json:"anonymous,omitempty" mapstructure:"anonymous,omitempty"` 302 | Message any `json:"message" mapstructure:"message"` 303 | RawMessage string `json:"raw_message,omitempty" mapstructure:"raw_message,omitempty"` 304 | Raw RawMessage `json:"raw,omitempty" mapstructure:"raw,omitempty"` 305 | Font int32 `json:"font" mapstructure:"font"` 306 | Sender Sender `json:"sender" mapstructure:"sender"` 307 | } 308 | 309 | func (m *Message) EventType() EventType { 310 | if m.MessageType == "private" { 311 | return MessagePrivate 312 | } 313 | return MessageGroup 314 | } 315 | 316 | type Anonymous struct { 317 | ID int64 `json:"id" mapstructure:"id"` 318 | Name string `json:"name" mapstructure:"name"` 319 | Flag string `json:"flag,omitempty" mapstructure:"flag,omitempty"` 320 | } 321 | 322 | type Sender struct { 323 | UserID int64 `json:"user_id" mapstructure:"user_id"` 324 | Nickname string `json:"nickname,omitempty" mapstructure:"nickname,omitempty"` 325 | Card string `json:"card,omitempty" mapstructure:"card,omitempty"` 326 | Sex string `json:"sex,omitempty" mapstructure:"sex,omitempty"` 327 | Age int32 `json:"age,omitempty" mapstructure:"age,omitempty"` 328 | Area string `json:"area,omitempty" mapstructure:"area,omitempty"` 329 | Level int32 `json:"level,omitempty" mapstructure:"level,omitempty"` 330 | Role string `json:"role,omitempty" mapstructure:"role,omitempty"` 331 | Title string `json:"title,omitempty" mapstructure:"title,omitempty"` 332 | } 333 | 334 | type RawMessage struct { 335 | Elements []Element `json:"elements" mapstructure:"elements"` 336 | } 337 | 338 | type Element struct { 339 | PicElement PicElement `json:"picElement,omitempty" mapstructure:"picElement,omitempty"` 340 | } 341 | 342 | type PicElement struct { 343 | Summary string `json:"summary,omitempty" mapstructure:"summary,omitempty"` 344 | } 345 | 346 | type LifeCycle struct { 347 | Event `mapstructure:",squash"` 348 | MetaEventType string `json:"meta_event_type" mapstructure:"meta_event_type"` 349 | SubType string `json:"sub_type" mapstructure:"sub_type"` 350 | } 351 | 352 | func (lc *LifeCycle) EventType() EventType { 353 | return MetaLifecycle 354 | } 355 | 356 | type Heartbeat struct { 357 | Event `mapstructure:",squash"` 358 | MetaEventType string `json:"meta_event_type" mapstructure:"meta_event_type"` 359 | Status any `json:"status" mapstructure:"status"` 360 | Interval int64 `json:"interval" mapstructure:"interval"` 361 | } 362 | 363 | func (h *Heartbeat) EventType() EventType { 364 | return MetaHeartbeat 365 | } 366 | 367 | type OfflineFile struct { 368 | Event `mapstructure:",squash"` 369 | NoticeType string `json:"notice_type" mapstructure:"notice_type"` 370 | GroupID int64 `json:"group_id" mapstructure:"group_id"` 371 | UserID int64 `json:"user_id" mapstructure:"user_id"` 372 | File FileInfo `json:"file" mapstructure:"file"` 373 | } 374 | 375 | func (of *OfflineFile) EventType() EventType { 376 | if of.NoticeType == "offline_file" { 377 | return NoticeOfflineFile 378 | } 379 | return NoticeGroupUpload 380 | } 381 | 382 | type GroupRecall struct { 383 | Event `mapstructure:",squash"` 384 | NoticeType string `json:"notice_type" mapstructure:"notice_type"` 385 | GroupID int64 `json:"group_id" mapstructure:"group_id"` 386 | UserID int64 `json:"user_id" mapstructure:"user_id"` 387 | OperatorID int64 `json:"operator_id" mapstructure:"operator_id"` 388 | MessageID int64 `json:"message_id" mapstructure:"message_id"` 389 | } 390 | 391 | func (g *GroupRecall) EventType() EventType { 392 | return NoticeGroupRecall 393 | } 394 | 395 | type FriendRecall struct { 396 | Event `mapstructure:",squash"` 397 | NoticeType string `json:"notice_type" mapstructure:"notice_type"` 398 | UserID int64 `json:"user_id" mapstructure:"user_id"` 399 | MessageID int64 `json:"message_id" mapstructure:"message_id"` 400 | } 401 | 402 | func (g *FriendRecall) EventType() EventType { 403 | return NoticeFriendRecall 404 | } 405 | 406 | type SegmentType string 407 | 408 | const ( 409 | Text SegmentType = "text" 410 | Face SegmentType = "face" 411 | MarketFace SegmentType = "mface" 412 | Image SegmentType = "image" 413 | Record SegmentType = "record" 414 | Video SegmentType = "video" 415 | File SegmentType = "file" 416 | At SegmentType = "at" 417 | Share SegmentType = "share" 418 | Location SegmentType = "location" 419 | Reply SegmentType = "reply" 420 | Forward SegmentType = "forward" 421 | Node SegmentType = "node" 422 | XML SegmentType = "xml" 423 | JSON SegmentType = "json" 424 | ) 425 | 426 | type ISegment interface { 427 | SegmentType() SegmentType 428 | } 429 | 430 | type Segment struct { 431 | Type string `json:"type"` 432 | Data map[string]interface{} `json:"data"` 433 | } 434 | 435 | func (s *Segment) SegmentType() SegmentType { 436 | return SegmentType(s.Type) 437 | } 438 | 439 | type TextSegment struct { 440 | Segment `mapstructure:",squash"` 441 | } 442 | 443 | type FaceSegment struct { 444 | Segment `mapstructure:",squash"` 445 | } 446 | 447 | type MarketFaceSegment struct { 448 | Segment `mapstructure:",squash"` 449 | } 450 | 451 | type ImageSegment struct { 452 | Segment `mapstructure:",squash"` 453 | IsSticker bool 454 | } 455 | 456 | type RecordSegment struct { 457 | Segment `mapstructure:",squash"` 458 | } 459 | 460 | type VideoSegment struct { 461 | Segment `mapstructure:",squash"` 462 | } 463 | 464 | type FileSegment struct { 465 | Segment `mapstructure:",squash"` 466 | } 467 | 468 | type AtSegment struct { 469 | Segment `mapstructure:",squash"` 470 | } 471 | 472 | type ShareSegment struct { 473 | Segment `mapstructure:",squash"` 474 | } 475 | 476 | type LocationSegment struct { 477 | Segment `mapstructure:",squash"` 478 | } 479 | 480 | type ReplySegment struct { 481 | Segment `mapstructure:",squash"` 482 | } 483 | 484 | type ForwardSegment struct { 485 | Segment `mapstructure:",squash"` 486 | } 487 | 488 | type NodeSegment struct { 489 | Segment `mapstructure:",squash"` 490 | } 491 | 492 | type XMLSegment struct { 493 | Segment `mapstructure:",squash"` 494 | } 495 | 496 | type JSONSegment struct { 497 | Segment `mapstructure:",squash"` 498 | } 499 | 500 | func (s *TextSegment) Content() string { 501 | return s.Data["text"].(string) 502 | } 503 | 504 | func (s *FaceSegment) ID() string { 505 | return s.Data["id"].(string) 506 | } 507 | 508 | func (s *MarketFaceSegment) Content() string { 509 | return s.Data["summary"].(string) 510 | } 511 | 512 | func (s *MarketFaceSegment) URL() string { 513 | return s.Data["url"].(string) 514 | } 515 | 516 | func (s *ImageSegment) File() string { 517 | return s.Data["file"].(string) 518 | } 519 | 520 | func (s *ImageSegment) URL() string { 521 | return s.Data["url"].(string) 522 | } 523 | 524 | func (s *RecordSegment) File() string { 525 | return s.Data["file"].(string) 526 | } 527 | 528 | func (s *VideoSegment) URL() string { 529 | return s.Data["url"].(string) 530 | } 531 | 532 | func (s *VideoSegment) File() string { 533 | return s.Data["file"].(string) 534 | } 535 | 536 | func (s *VideoSegment) FileID() string { 537 | return s.Data["file_id"].(string) 538 | } 539 | 540 | func (s *FileSegment) File() string { 541 | return s.Data["file"].(string) 542 | } 543 | 544 | func (s *FileSegment) FileID() string { 545 | return s.Data["file_id"].(string) 546 | } 547 | 548 | func (s *AtSegment) Target() string { 549 | return s.Data["qq"].(string) 550 | } 551 | 552 | func (s *ShareSegment) URL() string { 553 | return s.Data["url"].(string) 554 | } 555 | 556 | func (s *ShareSegment) Title() string { 557 | return s.Data["title"].(string) 558 | } 559 | 560 | func (s *ShareSegment) Content() string { 561 | return s.Data["content"].(string) 562 | } 563 | 564 | func (s *ShareSegment) Image() string { 565 | return s.Data["image"].(string) 566 | } 567 | 568 | func (s *LocationSegment) Latitude() float64 { 569 | return s.Data["lat"].(float64) 570 | } 571 | 572 | func (s *LocationSegment) Longitude() float64 { 573 | return s.Data["lon"].(float64) 574 | } 575 | 576 | func (s *LocationSegment) Title() string { 577 | return s.Data["title"].(string) 578 | } 579 | 580 | func (s *LocationSegment) Content() string { 581 | return s.Data["content"].(string) 582 | } 583 | 584 | func (s *ReplySegment) ID() string { 585 | return s.Data["id"].(string) 586 | } 587 | 588 | func (s *ForwardSegment) ID() string { 589 | return s.Data["id"].(string) 590 | } 591 | 592 | func (s *NodeSegment) ID() string { 593 | return s.Data["id"].(string) 594 | } 595 | 596 | func (s *XMLSegment) Content() string { 597 | return s.Data["data"].(string) 598 | } 599 | 600 | func (s *JSONSegment) Content() string { 601 | return s.Data["data"].(string) 602 | } 603 | 604 | func NewText(content string) *TextSegment { 605 | return &TextSegment{ 606 | Segment{ 607 | Type: string(Text), 608 | Data: map[string]interface{}{"text": content}, 609 | }, 610 | } 611 | } 612 | 613 | func NewFace(id string) *FaceSegment { 614 | return &FaceSegment{ 615 | Segment{ 616 | Type: string(Face), 617 | Data: map[string]interface{}{"id": id}, 618 | }, 619 | } 620 | } 621 | 622 | func NewImage(file string) *ImageSegment { 623 | return &ImageSegment{ 624 | Segment: Segment{ 625 | Type: string(Image), 626 | Data: map[string]interface{}{"file": file}, 627 | }, 628 | IsSticker: false, 629 | } 630 | } 631 | 632 | func NewRecord(file string) *RecordSegment { 633 | return &RecordSegment{ 634 | Segment{ 635 | Type: string(Record), 636 | Data: map[string]interface{}{"file": file}, 637 | }, 638 | } 639 | } 640 | 641 | func NewVideo(file string) *VideoSegment { 642 | return &VideoSegment{ 643 | Segment{ 644 | Type: string(Video), 645 | Data: map[string]interface{}{"file": file}, 646 | }, 647 | } 648 | } 649 | 650 | func NewFile(file, name string) *FileSegment { 651 | return &FileSegment{ 652 | Segment{ 653 | Type: string(File), 654 | Data: map[string]interface{}{"file": file, "name": name}, 655 | }, 656 | } 657 | } 658 | 659 | func NewAt(target string) *AtSegment { 660 | return &AtSegment{ 661 | Segment{ 662 | Type: string(At), 663 | Data: map[string]interface{}{"qq": target}, 664 | }, 665 | } 666 | } 667 | 668 | func NewShare(url, title, content, image string) *ShareSegment { 669 | return &ShareSegment{ 670 | Segment{ 671 | Type: string(Share), 672 | Data: map[string]interface{}{ 673 | "url": url, 674 | "title": title, 675 | "content": content, 676 | "image": image, 677 | }, 678 | }, 679 | } 680 | } 681 | 682 | func NewLocation(lat, lon float64, title, content string) *LocationSegment { 683 | return &LocationSegment{ 684 | Segment{ 685 | Type: string(Location), 686 | Data: map[string]interface{}{ 687 | "lat": lat, 688 | "lon": lon, 689 | "title": title, 690 | "content": content, 691 | }, 692 | }, 693 | } 694 | } 695 | 696 | func NewReply(id string) *ReplySegment { 697 | return &ReplySegment{ 698 | Segment{ 699 | Type: string(Reply), 700 | Data: map[string]interface{}{"id": id}, 701 | }, 702 | } 703 | } 704 | 705 | func NewNode(id string) *NodeSegment { 706 | return &NodeSegment{ 707 | Segment{ 708 | Type: string(Node), 709 | Data: map[string]interface{}{"id": id}, 710 | }, 711 | } 712 | } 713 | 714 | func NewXML(content string) *NodeSegment { 715 | return &NodeSegment{ 716 | Segment{ 717 | Type: string(XML), 718 | Data: map[string]interface{}{"data": content}, 719 | }, 720 | } 721 | } 722 | 723 | func NewJSON(content string) *NodeSegment { 724 | return &NodeSegment{ 725 | Segment{ 726 | Type: string(JSON), 727 | Data: map[string]interface{}{"data": content}, 728 | }, 729 | } 730 | } 731 | 732 | func UnmarshalPayload(m map[string]interface{}) (Payload, error) { 733 | if postType, ok := m["post_type"]; ok { 734 | switch postType { 735 | case "message": 736 | return unmarshalMessage(m) 737 | case "message_sent": 738 | return unmarshalMessage(m) 739 | case "meta_event": 740 | return unmarshalMeta(m) 741 | case "notice": 742 | return unmarshalNotice(m) 743 | case "request": 744 | return unmarshalEvent(m) 745 | } 746 | return nil, fmt.Errorf("event %s not support", postType) 747 | } else if _, ok := m["retcode"]; ok { 748 | return unmarshalResponse(m) 749 | } else if _, ok := m["action"]; ok { 750 | return unmarshalRequest(m) 751 | } 752 | 753 | return nil, errors.New("payload type not support") 754 | } 755 | 756 | func unmarshalMessage(m map[string]interface{}) (Payload, error) { 757 | var event Message 758 | if err := mapstructure.WeakDecode(m, &event); err != nil { 759 | return nil, err 760 | } 761 | 762 | if m["message"] != nil { 763 | event.Message = generateSegments(m["message"].([]interface{}), event.Raw.Elements) 764 | } else if m["content"] != nil { 765 | event.Message = generateSegments(m["content"].([]interface{}), event.Raw.Elements) 766 | } 767 | return &event, nil 768 | } 769 | 770 | func unmarshalMeta(m map[string]interface{}) (Payload, error) { 771 | switch m["meta_event_type"] { 772 | case "lifecycle": 773 | var event LifeCycle 774 | err := mapstructure.WeakDecode(m, &event) 775 | return &event, err 776 | case "heartbeat": 777 | var event Heartbeat 778 | err := mapstructure.WeakDecode(m, &event) 779 | return &event, err 780 | } 781 | 782 | return unmarshalEvent(m) 783 | } 784 | 785 | func unmarshalNotice(m map[string]interface{}) (Payload, error) { 786 | switch m["notice_type"] { 787 | case "offline_file", "group_upload": 788 | var event OfflineFile 789 | err := mapstructure.WeakDecode(m, &event) 790 | return &event, err 791 | case "group_recall": 792 | var event GroupRecall 793 | err := mapstructure.WeakDecode(m, &event) 794 | return &event, err 795 | case "friend_recall": 796 | var event FriendRecall 797 | err := mapstructure.WeakDecode(m, &event) 798 | return &event, err 799 | } 800 | 801 | return unmarshalEvent(m) 802 | } 803 | 804 | func unmarshalEvent(m map[string]interface{}) (Payload, error) { 805 | var event Event 806 | err := mapstructure.WeakDecode(m, &event) 807 | return &event, err 808 | } 809 | 810 | func unmarshalRequest(m map[string]interface{}) (Payload, error) { 811 | var event Request 812 | err := mapstructure.WeakDecode(m, &event) 813 | return &event, err 814 | } 815 | 816 | func unmarshalResponse(m map[string]interface{}) (Payload, error) { 817 | var event Response 818 | err := mapstructure.WeakDecode(m, &event) 819 | return &event, err 820 | } 821 | 822 | func generateSegments(d []interface{}, elements []Element) []ISegment { 823 | segments := []ISegment{} 824 | 825 | if len(d) != len(elements) { 826 | elements = make([]Element, len(d)) 827 | } 828 | 829 | for index, s := range d { 830 | switch s.(map[string]interface{})["type"].(string) { 831 | case string(Text): 832 | var segment TextSegment 833 | mapstructure.WeakDecode(s, &segment) 834 | segments = append(segments, &segment) 835 | case string(Face): 836 | var segment FaceSegment 837 | mapstructure.WeakDecode(s, &segment) 838 | segments = append(segments, &segment) 839 | case string(MarketFace): 840 | var segment MarketFaceSegment 841 | mapstructure.WeakDecode(s, &segment) 842 | segments = append(segments, &segment) 843 | case string(Image): 844 | var segment ImageSegment 845 | mapstructure.WeakDecode(s, &segment) 846 | segments = append(segments, &segment) 847 | if elements[index].PicElement.Summary == "[动画表情]" { 848 | segment.IsSticker = true 849 | } 850 | case string(Record): 851 | var segment RecordSegment 852 | mapstructure.WeakDecode(s, &segment) 853 | segments = append(segments, &segment) 854 | case string(Video): 855 | var segment VideoSegment 856 | mapstructure.WeakDecode(s, &segment) 857 | segments = append(segments, &segment) 858 | case string(File): 859 | var segment FileSegment 860 | mapstructure.WeakDecode(s, &segment) 861 | segments = append(segments, &segment) 862 | case string(At): 863 | var segment AtSegment 864 | mapstructure.WeakDecode(s, &segment) 865 | segments = append(segments, &segment) 866 | case string(Share): 867 | var segment ShareSegment 868 | mapstructure.WeakDecode(s, &segment) 869 | segments = append(segments, &segment) 870 | case string(Location): 871 | var segment LocationSegment 872 | mapstructure.WeakDecode(s, &segment) 873 | segments = append(segments, &segment) 874 | case string(Reply): 875 | var segment ReplySegment 876 | mapstructure.WeakDecode(s, &segment) 877 | segments = append(segments, &segment) 878 | case string(Forward): 879 | var segment ForwardSegment 880 | mapstructure.WeakDecode(s, &segment) 881 | segments = append(segments, &segment) 882 | case string(Node): 883 | var segment NodeSegment 884 | mapstructure.WeakDecode(s, &segment) 885 | segments = append(segments, &segment) 886 | case string(XML): 887 | var segment XMLSegment 888 | mapstructure.WeakDecode(s, &segment) 889 | segments = append(segments, &segment) 890 | case string(JSON): 891 | var segment JSONSegment 892 | mapstructure.WeakDecode(s, &segment) 893 | segments = append(segments, &segment) 894 | } 895 | } 896 | 897 | return segments 898 | } 899 | 900 | func PrettyPrint(v interface{}) (err error) { 901 | b, err := json.MarshalIndent(v, "", " ") 902 | if err == nil { 903 | fmt.Println(string(b)) 904 | } 905 | return 906 | } 907 | -------------------------------------------------------------------------------- /internal/master/processor.go: -------------------------------------------------------------------------------- 1 | package master 2 | 3 | import ( 4 | "bytes" 5 | "cmp" 6 | "errors" 7 | "fmt" 8 | "html" 9 | "image" 10 | "io" 11 | "os" 12 | "runtime/debug" 13 | "strings" 14 | 15 | _ "image/jpeg" 16 | _ "image/png" 17 | 18 | "github.com/duo/octopus/internal/common" 19 | "github.com/duo/octopus/internal/manager" 20 | 21 | "github.com/PaulSonOfLars/gotgbot/v2" 22 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 23 | "github.com/gabriel-vasile/mimetype" 24 | 25 | log "github.com/sirupsen/logrus" 26 | ) 27 | 28 | const ( 29 | imgMinSize = 1600 30 | imgMaxSize = 1200 31 | imgSizeRatio = 3.5 32 | imgSizeMaxRatio = 10 33 | ) 34 | 35 | type ChatInfo struct { 36 | id int64 37 | threadID int64 38 | title string 39 | } 40 | 41 | // read events from limb 42 | func (ms *MasterService) handleSlaveLoop() { 43 | defer func() { 44 | panicErr := recover() 45 | if panicErr != nil { 46 | log.Errorf("Panic in handle slave loop: %v\n%s", panicErr, debug.Stack()) 47 | } 48 | }() 49 | 50 | for event := range ms.in { 51 | if event.Type == common.EventSync { 52 | go ms.updateChats(event) 53 | } else { 54 | event := event 55 | go func() { 56 | ms.mutex.LockKey(event.Chat.ID) 57 | defer ms.mutex.UnlockKey(event.Chat.ID) 58 | 59 | ms.processSlaveEvent(event) 60 | }() 61 | } 62 | } 63 | } 64 | 65 | // process master message 66 | func (ms *MasterService) processMasterMessage(ctx *ext.Context) error { 67 | masterLimb := common.Limb{ 68 | Type: "telegram", 69 | UID: common.Itoa(ms.config.Master.AdminID), 70 | ChatID: common.Itoa(ctx.EffectiveChat.Id), 71 | }.String() 72 | 73 | rawMsg := ctx.EffectiveMessage 74 | 75 | log.Debugf("Receive Telegram message: %+v", rawMsg) 76 | 77 | // find linked limb chat 78 | if ctx.EffectiveChat.IsForum { 79 | topicID := rawMsg.MessageThreadId 80 | if topicID == 0 { 81 | return ms.replayLinkIssue(rawMsg, "*Chat on default topic not allowed.*") 82 | } else { 83 | if topic, err := manager.GetTopicByMaster(masterLimb, topicID); err != nil { 84 | log.Warnf("Get topic by master failed: %v", err) 85 | return err 86 | } else { 87 | if topic == nil { 88 | return ms.replayLinkIssue(rawMsg, "*No linked chat on topic found.*") 89 | } else { 90 | return ms.transferMasterMessage(ctx, topic.SlaveLimb) 91 | } 92 | } 93 | } 94 | } else if rawMsg.ReplyToMessage != nil { 95 | logMsg, err := manager.GetMessageByMasterMsgId( 96 | masterLimb, 97 | common.Itoa(rawMsg.ReplyToMessage.MessageId), 98 | ) 99 | if err != nil { 100 | log.Warnf("Get message by master message id failed: %v", err) 101 | return err 102 | } else if logMsg == nil { 103 | return ms.replayLinkIssue(rawMsg, "*No linked chat by reply found.*") 104 | } else { 105 | return ms.transferMasterMessage(ctx, logMsg.SlaveLimb) 106 | } 107 | } else if ctx.EffectiveChat.Type == "group" || ctx.EffectiveChat.Type == "supergroup" { 108 | if links, err := manager.GetLinksByMaster(masterLimb); err != nil { 109 | log.Warnf("Get links by master failed: %v", err) 110 | return err 111 | } else { 112 | if len(links) == 0 { 113 | return ms.replayLinkIssue(rawMsg, "*No linked chat on group found.*") 114 | } else if len(links) > 1 { 115 | return ms.replayLinkIssue(rawMsg, "*Multiple linked chat found.*") 116 | } else { 117 | return ms.transferMasterMessage(ctx, links[0].SlaveLimb) 118 | } 119 | } 120 | } else { 121 | return ms.replayLinkIssue(rawMsg, "*No linked chat found.*") 122 | } 123 | } 124 | 125 | // convert master message to octopus event and push 126 | func (ms *MasterService) transferMasterMessage(ctx *ext.Context, slaveLimb string) error { 127 | chat, err := manager.GetChat(slaveLimb) 128 | if err != nil { 129 | return err 130 | } 131 | if chat == nil { 132 | return errors.New(slaveLimb + " not found.") 133 | } 134 | 135 | limb, _ := common.LimbFromString(slaveLimb) 136 | meLimb := common.Limb{ 137 | Type: limb.Type, 138 | UID: limb.UID, 139 | ChatID: limb.UID, 140 | }.String() 141 | 142 | // get self 143 | me, err := manager.GetChat(meLimb) 144 | if err != nil { 145 | return err 146 | } 147 | if me == nil { 148 | return errors.New(meLimb + " not found.") 149 | } 150 | 151 | rawMsg := ctx.EffectiveMessage 152 | 153 | // generate a basic event 154 | event := &common.OctopusEvent{ 155 | Vendor: common.Vendor{ 156 | Type: limb.Type, 157 | UID: limb.UID, 158 | }, 159 | ID: common.Itoa(rawMsg.MessageId), 160 | Timestamp: rawMsg.Date, 161 | From: common.User{ 162 | ID: limb.UID, 163 | Username: me.Title, 164 | Remark: me.Title, 165 | }, 166 | Chat: common.Chat{ 167 | Type: chat.ChatType, 168 | ID: limb.ChatID, 169 | Title: chat.Title, 170 | }, 171 | Type: common.EventText, 172 | Content: rawMsg.Text, 173 | Callback: func(event *common.OctopusEvent, err error) { 174 | ms.transferCallback(rawMsg, event, err) 175 | }, 176 | } 177 | 178 | // process reply message 179 | if rawMsg.ReplyToMessage != nil && 180 | //(!ctx.EffectiveChat.IsForum || rawMsg.ReplyToMessage.MessageId != rawMsg.ReplyToMessage.MessageThreadId) { 181 | rawMsg.ReplyToMessage.MessageId != rawMsg.ReplyToMessage.MessageThreadId { 182 | masterLimb := common.Limb{ 183 | Type: "telegram", 184 | UID: common.Itoa(ms.config.Master.AdminID), 185 | ChatID: common.Itoa(ctx.EffectiveChat.Id), 186 | }.String() 187 | logMsg, err := manager.GetMessageByMasterMsgId( 188 | masterLimb, 189 | common.Itoa(rawMsg.ReplyToMessage.MessageId), 190 | ) 191 | if err == nil && logMsg != nil && logMsg.SlaveMsgID != "0" { 192 | event.Reply = &common.ReplyInfo{ 193 | ID: logMsg.SlaveMsgID, 194 | Timestamp: logMsg.Timestamp, 195 | Sender: logMsg.SlaveSender, 196 | Content: logMsg.Content, 197 | } 198 | } 199 | } 200 | 201 | if rawMsg.Photo != nil { 202 | // TODO: group media 203 | event.Type = common.EventPhoto 204 | if blob, err := ms.download(rawMsg.Photo[len(rawMsg.Photo)-1].FileId); err != nil { 205 | return err 206 | } else { 207 | event.Data = []*common.BlobData{blob} 208 | } 209 | } else if rawMsg.Sticker != nil { 210 | event.Type = common.EventSticker 211 | if blob, err := ms.download(rawMsg.Sticker.FileId); err != nil { 212 | return err 213 | } else { 214 | event.Data = blob 215 | } 216 | } else if rawMsg.Animation != nil { 217 | event.Type = common.EventSticker 218 | if blob, err := ms.download(rawMsg.Animation.FileId); err != nil { 219 | return err 220 | } else { 221 | event.Data = blob 222 | } 223 | } else if rawMsg.Voice != nil { 224 | event.Type = common.EventAudio 225 | if blob, err := ms.download(rawMsg.Voice.FileId); err != nil { 226 | return err 227 | } else { 228 | event.Data = blob 229 | } 230 | } else if rawMsg.Audio != nil { 231 | event.Type = common.EventAudio 232 | if blob, err := ms.download(rawMsg.Audio.FileId); err != nil { 233 | return err 234 | } else { 235 | if rawMsg.Audio.FileName != "" { 236 | blob.Name = rawMsg.Audio.FileName 237 | } 238 | event.Data = blob 239 | } 240 | } else if rawMsg.Video != nil { 241 | event.Type = common.EventVideo 242 | if blob, err := ms.download(rawMsg.Video.FileId); err != nil { 243 | return err 244 | } else { 245 | if rawMsg.Video.FileName != "" { 246 | blob.Name = rawMsg.Video.FileName 247 | } 248 | event.Data = blob 249 | } 250 | } else if rawMsg.Document != nil { 251 | event.Type = common.EventFile 252 | if blob, err := ms.download(rawMsg.Document.FileId); err != nil { 253 | return err 254 | } else { 255 | if rawMsg.Document.FileName != "" { 256 | blob.Name = rawMsg.Document.FileName 257 | } 258 | event.Data = blob 259 | } 260 | } else if rawMsg.Venue != nil { 261 | event.Type = common.EventLocation 262 | event.Data = &common.LocationData{ 263 | Name: rawMsg.Venue.Title, 264 | Address: rawMsg.Venue.Address, 265 | Longitude: rawMsg.Venue.Location.Longitude, 266 | Latitude: rawMsg.Venue.Location.Latitude, 267 | } 268 | } else if rawMsg.Location != nil { 269 | event.Type = common.EventLocation 270 | event.Data = &common.LocationData{ 271 | Name: "Location", 272 | Address: fmt.Sprintf( 273 | "Latitude: %.5f Longitude: %.5f", 274 | rawMsg.Location.Latitude, 275 | rawMsg.Location.Longitude, 276 | ), 277 | Longitude: rawMsg.Location.Longitude, 278 | Latitude: rawMsg.Location.Latitude, 279 | } 280 | } else if rawMsg.Text == "" { 281 | return fmt.Errorf("message type not support: %+v", rawMsg) 282 | } 283 | 284 | ms.out <- event 285 | 286 | return nil 287 | } 288 | 289 | // process limb client event response 290 | func (ms *MasterService) transferCallback(rawMSg *gotgbot.Message, event *common.OctopusEvent, cbErr error) { 291 | if cbErr != nil { 292 | ms.replayLinkIssue(rawMSg, fmt.Sprintf("*[FAIL]: %s*", strings.NewReplacer("*", "\\*").Replace(cbErr.Error()))) 293 | return 294 | } 295 | 296 | masterLimb := common.Limb{ 297 | Type: "telegram", 298 | UID: common.Itoa(ms.config.Master.AdminID), 299 | ChatID: common.Itoa(rawMSg.Chat.Id), 300 | }.String() 301 | slaveLimb := common.Limb{ 302 | Type: event.Vendor.Type, 303 | UID: event.Vendor.UID, 304 | ChatID: event.Chat.ID, 305 | }.String() 306 | 307 | msg := &manager.Message{ 308 | MasterLimb: masterLimb, 309 | MasterMsgID: common.Itoa(rawMSg.MessageId), 310 | MasterMsgThreadID: common.Itoa(rawMSg.MessageThreadId), 311 | SlaveLimb: slaveLimb, 312 | SlaveMsgID: event.ID, 313 | SlaveSender: event.From.ID, 314 | Content: event.Content, 315 | Timestamp: event.Timestamp, 316 | } 317 | 318 | if err := manager.AddMessage(msg); err != nil { 319 | log.Warnf("Failed to add message: %+v %v", msg, err) 320 | } else { 321 | log.Debugf("Add message: %+v", msg) 322 | } 323 | } 324 | 325 | // process events from limb client 326 | func (ms *MasterService) processSlaveEvent(event *common.OctopusEvent) { 327 | defer func() { 328 | panicErr := recover() 329 | if panicErr != nil { 330 | log.Errorf("Panic in handle slave event: %+v %v\n%s", event, panicErr, debug.Stack()) 331 | } 332 | }() 333 | 334 | log.Debugf("Receive octopus event: %+v", event) 335 | 336 | adminID := ms.config.Master.AdminID 337 | 338 | // handle observe event 339 | if event.Type == common.EventObserve { 340 | ms.bot.SendMessage( 341 | adminID, 342 | fmt.Sprintf("*[INFO]: %s*", strings.NewReplacer("*", "\\*").Replace(event.Content)), 343 | &gotgbot.SendMessageOpts{ 344 | ParseMode: "Markdown", 345 | }) 346 | return 347 | } 348 | 349 | slaveLimb := common.Limb{ 350 | Type: event.Vendor.Type, 351 | UID: event.Vendor.UID, 352 | ChatID: event.Chat.ID, 353 | }.String() 354 | 355 | links, err := manager.GetLinksBySlave(slaveLimb) 356 | if err != nil { 357 | log.Warnf("Get links by slave failed: %v", err) 358 | return 359 | } 360 | log.Debugf("Links by slave(%s): %+v", slaveLimb, links) 361 | 362 | var replyMap = map[int64]int64{} 363 | // get reply map for quote and revoke 364 | if event.Reply != nil { 365 | messages, err := manager.GetMessagesBySlaveReply(slaveLimb, event.Reply) 366 | if err != nil { 367 | log.Warnf("Get reply messages failed: %v", err) 368 | return 369 | } 370 | for _, m := range messages { 371 | limb, err := common.LimbFromString(m.MasterLimb) 372 | if err != nil { 373 | log.Warnf("Parse limb(%v) failed: %v", m.MasterLimb, err) 374 | continue 375 | } 376 | chatID, err := common.Atoi(limb.ChatID) 377 | if err != nil { 378 | log.Warnf("Parse chatId(%v) failed: %v", limb.ChatID, err) 379 | continue 380 | } 381 | masterMsgID, err := common.Atoi(m.MasterMsgID) 382 | if err != nil { 383 | log.Warnf("Parse mastetMsgId(%v) failed: %v", m.MasterMsgID, err) 384 | continue 385 | } 386 | replyMap[chatID] = masterMsgID 387 | } 388 | } 389 | 390 | chats := []*ChatInfo{} 391 | 392 | if len(links) > 0 { 393 | // find linked Telegram chat 394 | for _, l := range links { 395 | limb, err := common.LimbFromString(l.MasterLimb) 396 | if err != nil { 397 | log.Warnf("Parse limb(%v) failed: %v", l.MasterLimb, err) 398 | continue 399 | } 400 | chatID, err := common.Atoi(limb.ChatID) 401 | if err != nil { 402 | log.Warnf("Parse chatId(%v) failed: %v", limb.ChatID, err) 403 | continue 404 | } 405 | chat, err := ms.bot.GetChat(chatID, nil) 406 | if err != nil { 407 | log.Warnf("Failed to get chat(%d) info from Telegram: %v", chatID, err) 408 | continue 409 | } 410 | if chat.IsForum { 411 | chats = append(chats, ms.createForumChatInfo(chatID, event)) 412 | } else { 413 | chats = append(chats, &ChatInfo{ 414 | id: chatID, 415 | title: fmt.Sprintf("%s:", displayName(&event.From)), 416 | }) 417 | } 418 | } 419 | } else if chatID, ok := ms.archiveChats[event.Vendor.String()]; ok { 420 | // find archive supergroup (topic enabled) 421 | chats = append(chats, ms.createForumChatInfo(chatID, event)) 422 | } else { 423 | var title string 424 | if event.Chat.Type == "private" { 425 | title = fmt.Sprintf("👤 %s:", displayName(&event.From)) 426 | } else { 427 | title = fmt.Sprintf("👥 %s [%s]:", displayName(&event.From), event.Chat.Title) 428 | } 429 | chats = append(chats, &ChatInfo{ 430 | id: adminID, 431 | title: title, 432 | }) 433 | } 434 | 435 | for _, chat := range chats { 436 | var replyToMessageID int64 = 0 437 | if val, ok := replyMap[chat.id]; ok { 438 | replyToMessageID = val 439 | } 440 | 441 | switch event.Type { 442 | case common.EventRevoke: 443 | ms.bot.SendChatAction(chat.id, "typing", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 444 | resp, err := ms.bot.SendMessage( 445 | chat.id, 446 | fmt.Sprintf( 447 | "%s\n~%s~", 448 | common.EscapeText("MarkdownV2", chat.title), 449 | common.EscapeText("MarkdownV2", event.Content), 450 | ), 451 | &gotgbot.SendMessageOpts{ 452 | ParseMode: "MarkdownV2", 453 | MessageThreadId: chat.threadID, 454 | ReplyParameters: &gotgbot.ReplyParameters{ 455 | MessageId: replyToMessageID, 456 | }, 457 | }, 458 | ) 459 | ms.logMessage(chat, event, resp, err) 460 | case common.EventText, common.EventSystem: 461 | ms.bot.SendChatAction(chat.id, "typing", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 462 | resp, err := ms.bot.SendMessage( 463 | chat.id, 464 | fmt.Sprintf("%s\n%s", chat.title, event.Content), 465 | &gotgbot.SendMessageOpts{ 466 | MessageThreadId: chat.threadID, 467 | ReplyParameters: &gotgbot.ReplyParameters{ 468 | MessageId: replyToMessageID, 469 | }, 470 | }, 471 | ) 472 | ms.logMessage(chat, event, resp, err) 473 | case common.EventVoIP: 474 | ms.bot.SendChatAction(chat.id, "typing", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 475 | resp, err := ms.bot.SendMessage( 476 | chat.id, 477 | fmt.Sprintf( 478 | "%s\n_%s_", 479 | common.EscapeText("MarkdownV2", chat.title), 480 | common.EscapeText("MarkdownV2", event.Content), 481 | ), 482 | &gotgbot.SendMessageOpts{ 483 | ParseMode: "MarkdownV2", 484 | MessageThreadId: chat.threadID, 485 | ReplyParameters: &gotgbot.ReplyParameters{ 486 | MessageId: replyToMessageID, 487 | }, 488 | }, 489 | ) 490 | ms.logMessage(chat, event, resp, err) 491 | case common.EventLocation: 492 | location := event.Data.(*common.LocationData) 493 | resp, err := ms.bot.SendVenue( 494 | chat.id, 495 | location.Latitude, 496 | location.Longitude, 497 | fmt.Sprintf("%s %s", chat.title, location.Name), 498 | location.Address, 499 | &gotgbot.SendVenueOpts{ 500 | MessageThreadId: chat.threadID, 501 | ReplyParameters: &gotgbot.ReplyParameters{ 502 | MessageId: replyToMessageID, 503 | }, 504 | }, 505 | ) 506 | ms.logMessage(chat, event, resp, err) 507 | case common.EventApp: 508 | app := event.Data.(*common.AppData) 509 | text := fmt.Sprintf("%s\n%s\n\n%s", 510 | chat.title, 511 | html.EscapeString(app.Title), 512 | html.EscapeString(app.Description), 513 | ) 514 | if app.URL != "" { 515 | source := html.EscapeString(app.Source) 516 | if source == "" { 517 | source = app.URL 518 | } 519 | text = fmt.Sprintf("%s\n\nvia %s", 520 | text, 521 | app.URL, 522 | source, 523 | ) 524 | } 525 | 526 | if ms.config.Master.Telegraph.Enable && len(ms.config.Master.Telegraph.Tokens) > 0 && app.Content != "" { 527 | if page, err := ms.postApp(app); err == nil { 528 | text = fmt.Sprintf("%s\n%s", 529 | chat.title, 530 | page.URL, 531 | page.Title, 532 | ) 533 | } 534 | } 535 | 536 | ms.bot.SendChatAction(chat.id, "typing", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 537 | resp, err := ms.bot.SendMessage( 538 | chat.id, 539 | text, 540 | &gotgbot.SendMessageOpts{ 541 | MessageThreadId: chat.threadID, 542 | ReplyParameters: &gotgbot.ReplyParameters{ 543 | MessageId: replyToMessageID, 544 | }, 545 | ParseMode: "HTML", 546 | }, 547 | ) 548 | ms.logMessage(chat, event, resp, err) 549 | case common.EventAudio: 550 | ms.bot.SendChatAction(chat.id, "upload_voice", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 551 | blob := event.Data.(*common.BlobData) 552 | resp, err := ms.bot.SendVoice( 553 | chat.id, 554 | gotgbot.InputFileByReader(blob.Name, bytes.NewReader(blob.Binary)), 555 | &gotgbot.SendVoiceOpts{ 556 | Caption: fmt.Sprintf("%s\n%s", chat.title, event.Content), 557 | MessageThreadId: chat.threadID, 558 | ReplyParameters: &gotgbot.ReplyParameters{ 559 | MessageId: replyToMessageID, 560 | }, 561 | }, 562 | ) 563 | ms.logMessage(chat, event, resp, err) 564 | case common.EventVideo: 565 | ms.bot.SendChatAction(chat.id, "upload_video", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 566 | blob := event.Data.(*common.BlobData) 567 | //mime := mimetype.Detect(blob.Binary) 568 | //fileName := fmt.Sprintf("%s%s", msg.ID, mime.Extension()) 569 | text := fmt.Sprintf("%s\n%s", chat.title, event.Content) 570 | resp, err := ms.bot.SendVideo( 571 | chat.id, 572 | //&gotgbot.NamedFile{ 573 | // File: bytes.NewReader(blob.Binary), 574 | // FileName: fileName, 575 | //}, 576 | gotgbot.InputFileByReader(blob.Name, bytes.NewReader(blob.Binary)), 577 | &gotgbot.SendVideoOpts{ 578 | Caption: text, 579 | MessageThreadId: chat.threadID, 580 | ReplyParameters: &gotgbot.ReplyParameters{ 581 | MessageId: replyToMessageID, 582 | }, 583 | }, 584 | ) 585 | ms.logMessage(chat, event, resp, err) 586 | case common.EventFile: 587 | ms.bot.SendChatAction(chat.id, "upload_document", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 588 | blob := event.Data.(*common.BlobData) 589 | resp, err := ms.bot.SendDocument( 590 | chat.id, 591 | gotgbot.InputFileByReader(blob.Name, bytes.NewReader(blob.Binary)), 592 | &gotgbot.SendDocumentOpts{ 593 | Caption: chat.title, 594 | MessageThreadId: chat.threadID, 595 | ReplyParameters: &gotgbot.ReplyParameters{ 596 | MessageId: replyToMessageID, 597 | }, 598 | }, 599 | ) 600 | ms.logMessage(chat, event, resp, err) 601 | case common.EventSticker: 602 | blob := event.Data.(*common.BlobData) 603 | if strings.HasSuffix(blob.Mime, "png") || strings.HasSuffix(blob.Mime, "webp") { 604 | resp, err := ms.bot.SendSticker( 605 | chat.id, 606 | gotgbot.InputFileByReader(blob.Name, bytes.NewReader(blob.Binary)), 607 | &gotgbot.SendStickerOpts{ 608 | MessageThreadId: chat.threadID, 609 | ReplyParameters: &gotgbot.ReplyParameters{ 610 | MessageId: replyToMessageID, 611 | }, 612 | ReplyMarkup: gotgbot.InlineKeyboardMarkup{ 613 | InlineKeyboard: [][]gotgbot.InlineKeyboardButton{{ 614 | gotgbot.InlineKeyboardButton{ 615 | Text: fmt.Sprintf("%s\n%s", chat.title, event.Content), 616 | Url: "tg://sticker", 617 | }, 618 | }}, 619 | }, 620 | }, 621 | ) 622 | ms.logMessage(chat, event, resp, err) 623 | } else { 624 | ms.sendPhoto(chat, replyToMessageID, blob, event) 625 | } 626 | case common.EventPhoto: 627 | photos := event.Data.([]*common.BlobData) 628 | if len(photos) == 1 { 629 | ms.sendPhoto(chat, replyToMessageID, photos[0], event) 630 | } else { 631 | text := fmt.Sprintf("%s\n%s", chat.title, event.Content) 632 | var mediaGroup []gotgbot.InputMedia 633 | for i, photo := range photos { 634 | if i == 10 { 635 | break 636 | } 637 | 638 | caption := "" 639 | if i == 0 { 640 | caption = text 641 | } 642 | 643 | mediaGroup = append(mediaGroup, gotgbot.InputMediaPhoto{ 644 | Media: gotgbot.InputFileByReader(photo.Name, bytes.NewReader(photo.Binary)), 645 | Caption: caption, 646 | }) 647 | } 648 | resps, err := ms.bot.SendMediaGroup( 649 | chat.id, 650 | mediaGroup, 651 | &gotgbot.SendMediaGroupOpts{ 652 | MessageThreadId: chat.threadID, 653 | ReplyParameters: &gotgbot.ReplyParameters{ 654 | MessageId: replyToMessageID, 655 | }, 656 | }, 657 | ) 658 | if err != nil { 659 | log.Warnf("Failed to send to Telegram (chat %d, %d): %v", chat.id, chat.threadID, err) 660 | } else { 661 | for _, resp := range resps { 662 | ms.logMessage(chat, event, &resp, err) 663 | } 664 | } 665 | } 666 | default: 667 | log.Warnf("event type not support: %s", event.Type) 668 | } 669 | } 670 | } 671 | 672 | // update chats from limb client 673 | func (ms *MasterService) updateChats(event *common.OctopusEvent) { 674 | defer func() { 675 | panicErr := recover() 676 | if panicErr != nil { 677 | log.Errorf("Panic in update chats event: %+v %v\n%s", event, panicErr, debug.Stack()) 678 | } 679 | }() 680 | 681 | chats := event.Data.([]*common.Chat) 682 | log.Infof("Update chats for %s, count: %d", event.Vendor, len(chats)) 683 | for _, c := range chats { 684 | limb := common.Limb{ 685 | Type: event.Vendor.Type, 686 | UID: event.Vendor.UID, 687 | ChatID: c.ID, 688 | }.String() 689 | chat := &manager.Chat{ 690 | Limb: limb, 691 | ChatType: c.Type, 692 | Title: c.Title, 693 | } 694 | if err := manager.AddOrUpdateChat(chat); err != nil { 695 | log.Warnf("Failed to add or update chat: %v", err) 696 | } 697 | } 698 | } 699 | 700 | func (ms *MasterService) sendPhoto(chat *ChatInfo, replyToMessageID int64, photo *common.BlobData, event *common.OctopusEvent) { 701 | text := fmt.Sprintf("%s\n%s", chat.title, event.Content) 702 | 703 | ms.bot.SendChatAction(chat.id, "upload_photo", &gotgbot.SendChatActionOpts{MessageThreadId: chat.threadID}) 704 | mime := mimetype.Detect(photo.Binary) 705 | if mime.String() == "image/gif" { 706 | resp, err := ms.bot.SendAnimation( 707 | chat.id, 708 | gotgbot.InputFileByReader(photo.Name+".gif", bytes.NewReader(photo.Binary)), 709 | &gotgbot.SendAnimationOpts{ 710 | Caption: text, 711 | MessageThreadId: chat.threadID, 712 | ReplyParameters: &gotgbot.ReplyParameters{ 713 | MessageId: replyToMessageID, 714 | }, 715 | }, 716 | ) 717 | ms.logMessage(chat, event, resp, err) 718 | } else if isSendAsFile(photo.Binary) { 719 | resp, err := ms.bot.SendDocument( 720 | chat.id, 721 | gotgbot.InputFileByReader(photo.Name, bytes.NewReader(photo.Binary)), 722 | &gotgbot.SendDocumentOpts{ 723 | Caption: text, 724 | MessageThreadId: chat.threadID, 725 | ReplyParameters: &gotgbot.ReplyParameters{ 726 | MessageId: replyToMessageID, 727 | }, 728 | }, 729 | ) 730 | ms.logMessage(chat, event, resp, err) 731 | } else { 732 | resp, err := ms.bot.SendPhoto( 733 | chat.id, 734 | gotgbot.InputFileByReader(photo.Name, bytes.NewReader(photo.Binary)), 735 | &gotgbot.SendPhotoOpts{ 736 | Caption: text, 737 | MessageThreadId: chat.threadID, 738 | ReplyParameters: &gotgbot.ReplyParameters{ 739 | MessageId: replyToMessageID, 740 | }, 741 | }, 742 | ) 743 | ms.logMessage(chat, event, resp, err) 744 | } 745 | } 746 | 747 | func (ms *MasterService) logMessage(chat *ChatInfo, event *common.OctopusEvent, resp *gotgbot.Message, err error) { 748 | if err != nil { 749 | log.Warnf("Failed to send to Telegram (chat %d, %d): %v", chat.id, chat.threadID, err) 750 | } else { 751 | masterLimb := common.Limb{ 752 | Type: "telegram", 753 | UID: common.Itoa(ms.config.Master.AdminID), 754 | ChatID: common.Itoa(resp.Chat.Id), 755 | }.String() 756 | slaveLimb := common.Limb{ 757 | Type: event.Vendor.Type, 758 | UID: event.Vendor.UID, 759 | ChatID: event.Chat.ID, 760 | }.String() 761 | msg := &manager.Message{ 762 | MasterLimb: masterLimb, 763 | MasterMsgID: common.Itoa(resp.MessageId), 764 | MasterMsgThreadID: common.Itoa(resp.MessageThreadId), 765 | SlaveLimb: slaveLimb, 766 | SlaveMsgID: event.ID, 767 | SlaveSender: event.From.ID, 768 | Content: event.Content, 769 | Timestamp: event.Timestamp, 770 | } 771 | if err := manager.AddMessage(msg); err != nil { 772 | log.Warnf("Failed to add message %+v: %v", msg, err) 773 | } else { 774 | log.Debugf("Add message: %+v", msg) 775 | } 776 | } 777 | } 778 | 779 | func (ms *MasterService) replayLinkIssue(msg *gotgbot.Message, text string) error { 780 | _, err := msg.Reply(ms.bot, text, &gotgbot.SendMessageOpts{ 781 | ParseMode: "Markdown", 782 | MessageThreadId: msg.MessageThreadId, 783 | }) 784 | return err 785 | } 786 | 787 | func (ms *MasterService) createForumChatInfo(chatID int64, event *common.OctopusEvent) *ChatInfo { 788 | masterLimb := common.Limb{ 789 | Type: "telegram", 790 | UID: common.Itoa(ms.config.Master.AdminID), 791 | ChatID: common.Itoa(chatID), 792 | }.String() 793 | slaveLimb := common.Limb{ 794 | Type: event.Vendor.Type, 795 | UID: event.Vendor.UID, 796 | ChatID: event.Chat.ID, 797 | }.String() 798 | 799 | topic := ms.getOrCreateTopic(chatID, event.Chat.Title, masterLimb, slaveLimb) 800 | if topic == nil { 801 | var title string 802 | if event.Chat.Type == "private" { 803 | title = fmt.Sprintf("👤 %s:", displayName(&event.From)) 804 | } else { 805 | title = fmt.Sprintf("👥 %s [%s]:", displayName(&event.From), event.Chat.Title) 806 | } 807 | return &ChatInfo{ 808 | id: chatID, 809 | title: title, 810 | } 811 | } else { 812 | topicID, _ := common.Atoi(topic.TopicID) 813 | return &ChatInfo{ 814 | id: chatID, 815 | threadID: topicID, 816 | title: fmt.Sprintf("%s:", displayName(&event.From)), 817 | } 818 | } 819 | } 820 | 821 | func (ms *MasterService) getOrCreateTopic(chatID int64, title, masterLimb, slaveLimb string) *manager.Topic { 822 | topic, err := manager.GetTopic(masterLimb, slaveLimb) 823 | if err != nil { 824 | log.Warnf("Failed to get topic: %v", err) 825 | } else if topic == nil { 826 | resp, err := ms.bot.CreateForumTopic(chatID, title, &gotgbot.CreateForumTopicOpts{}) 827 | if err != nil { 828 | log.Warnf("Failed to create topic: %v", err) 829 | } else { 830 | topic = &manager.Topic{ 831 | MasterLimb: masterLimb, 832 | SlaveLimb: slaveLimb, 833 | TopicID: common.Itoa(resp.MessageThreadId), 834 | } 835 | if err := manager.AddTopic(topic); err != nil { 836 | log.Warnf("Failed to add topic: %v", err) 837 | } 838 | } 839 | } 840 | return topic 841 | } 842 | 843 | func (ms *MasterService) download(fileID string) (*common.BlobData, error) { 844 | if file, err := ms.bot.GetFile(fileID, &gotgbot.GetFileOpts{}); err != nil { 845 | return nil, err 846 | } else { 847 | var data []byte 848 | 849 | if ms.config.Master.LocalMode { 850 | data, err = os.ReadFile(file.FilePath) 851 | if err != nil { 852 | return nil, err 853 | } 854 | } else { 855 | response, err := ms.client.Get(file.URL(ms.bot, ms.opts)) 856 | if err != nil { 857 | return nil, err 858 | } 859 | defer response.Body.Close() 860 | data, err = io.ReadAll(response.Body) 861 | if err != nil { 862 | return nil, err 863 | } 864 | 865 | } 866 | 867 | mime := mimetype.Detect(data) 868 | return &common.BlobData{ 869 | Name: fmt.Sprintf("%s%s", file.FileUniqueId, mime.Extension()), 870 | Mime: mime.String(), 871 | Binary: data, 872 | }, nil 873 | } 874 | } 875 | 876 | func displayName(user *common.User) string { 877 | return cmp.Or(user.Remark, user.Username, user.ID) 878 | } 879 | 880 | func isSendAsFile(data []byte) bool { 881 | image, _, err := image.DecodeConfig(bytes.NewReader(data)) 882 | if err == nil { 883 | var maxSize int 884 | var minSize int 885 | if image.Height > image.Width { 886 | maxSize = image.Height 887 | minSize = image.Width 888 | } else { 889 | maxSize = image.Width 890 | minSize = image.Height 891 | } 892 | imgRatio := float32(maxSize) / float32(minSize) 893 | 894 | if minSize > imgMinSize { 895 | return true 896 | } 897 | if maxSize > imgMaxSize && imgRatio > imgSizeRatio { 898 | return true 899 | } 900 | if imgRatio >= imgSizeMaxRatio { 901 | return true 902 | } 903 | } else { 904 | log.Warnf("Deocde image(%s) failed: %v", mimetype.Detect(data), err) 905 | } 906 | 907 | return false 908 | } 909 | -------------------------------------------------------------------------------- /internal/slave/onebot_client.go: -------------------------------------------------------------------------------- 1 | package slave 2 | 3 | import ( 4 | "cmp" 5 | "context" 6 | "encoding/base64" 7 | "fmt" 8 | "strings" 9 | "sync" 10 | "sync/atomic" 11 | "time" 12 | 13 | "github.com/duo/octopus/internal/common" 14 | "github.com/duo/octopus/internal/filter" 15 | "github.com/duo/octopus/internal/onebot" 16 | "github.com/tidwall/gjson" 17 | 18 | "github.com/gabriel-vasile/mimetype" 19 | "github.com/gorilla/websocket" 20 | "github.com/mitchellh/mapstructure" 21 | 22 | log "github.com/sirupsen/logrus" 23 | ) 24 | 25 | const ( 26 | LAGRANGE_ONEBOT string = "Lagrange.OneBot" 27 | ) 28 | 29 | type OnebotClient struct { 30 | vendor *common.Vendor 31 | agent string 32 | config *common.Configure 33 | 34 | self *onebot.FriendInfo 35 | friends map[int64]*onebot.FriendInfo 36 | groups map[int64]*onebot.GroupInfo 37 | 38 | conn *websocket.Conn 39 | out chan<- *common.OctopusEvent 40 | 41 | s2m filter.EventFilterChain 42 | m2s filter.EventFilterChain 43 | 44 | writeLock sync.Mutex 45 | 46 | websocketRequests map[string]chan<- *onebot.Response 47 | websocketRequestsLock sync.RWMutex 48 | websocketRequestID int64 49 | 50 | mutex common.KeyMutex 51 | } 52 | 53 | func NewOnebotClient(vendor *common.Vendor, agent string, config *common.Configure, conn *websocket.Conn, out chan<- *common.OctopusEvent) *OnebotClient { 54 | log.Infof("OnebotClient(%s) websocket connected", vendor) 55 | 56 | m2s := filter.NewEventFilterChain( 57 | filter.StickerM2SFilter{}, 58 | filter.VoiceM2SFilter{}, 59 | ) 60 | s2m := filter.NewEventFilterChain( 61 | filter.VoiceS2MFilter{}, 62 | filter.EmoticonS2MFilter{}, 63 | filter.StickerS2MFilter{}, 64 | ) 65 | 66 | return &OnebotClient{ 67 | vendor: vendor, 68 | agent: agent, 69 | config: config, 70 | friends: make(map[int64]*onebot.FriendInfo), 71 | groups: make(map[int64]*onebot.GroupInfo), 72 | conn: conn, 73 | out: out, 74 | m2s: m2s, 75 | s2m: s2m, 76 | websocketRequests: make(map[string]chan<- *onebot.Response), 77 | mutex: common.NewHashed(47), 78 | } 79 | } 80 | 81 | func (oc *OnebotClient) Vendor() string { 82 | return oc.vendor.String() 83 | } 84 | 85 | // read message from ontbot client 86 | func (oc *OnebotClient) run(stopFunc func()) { 87 | defer func() { 88 | log.Infof("OnebotClient(%s) disconnected from websocket", oc.vendor) 89 | _ = oc.conn.Close() 90 | stopFunc() 91 | }() 92 | 93 | for { 94 | var m map[string]interface{} 95 | if err := oc.conn.ReadJSON(&m); err != nil { 96 | log.Warnf("Error reading from websocket: %v", err) 97 | break 98 | } 99 | payload, err := onebot.UnmarshalPayload(m) 100 | if err != nil { 101 | log.Warnf("Failed to unmarshal payload: %v", err) 102 | continue 103 | } 104 | 105 | switch payload.PayloadType() { 106 | case onebot.PaylaodRequest: 107 | log.Warnf("Request %s not support", payload.(*onebot.Request).Action) 108 | case onebot.PayloadResponse: 109 | go oc.processResponse(payload.(*onebot.Response)) 110 | case onebot.PayloadEvent: 111 | go oc.processEvent(payload.(onebot.IEvent)) 112 | } 113 | } 114 | } 115 | 116 | // send event to onebot client, and return response 117 | func (oc *OnebotClient) SendEvent(event *common.OctopusEvent) (*common.OctopusEvent, error) { 118 | log.Debugf("Receive Octopus event: %+v", event) 119 | 120 | event = oc.m2s.Apply(event) 121 | 122 | targetID, err := common.Atoi(event.Chat.ID) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | segments := []onebot.ISegment{} 128 | 129 | if event.Reply != nil { 130 | segments = append(segments, onebot.NewReply(event.Reply.ID)) 131 | } 132 | 133 | switch event.Type { 134 | case common.EventText: 135 | segments = append(segments, onebot.NewText(event.Content)) 136 | case common.EventPhoto: 137 | photos := event.Data.([]*common.BlobData) 138 | for _, photo := range photos { 139 | binary := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(photo.Binary)) 140 | segments = append(segments, onebot.NewImage(binary)) 141 | } 142 | case common.EventSticker: 143 | blob := event.Data.(*common.BlobData) 144 | binary := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(blob.Binary)) 145 | segments = append(segments, onebot.NewImage(binary)) 146 | case common.EventVideo: 147 | blob := event.Data.(*common.BlobData) 148 | binary := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(blob.Binary)) 149 | segments = append(segments, onebot.NewVideo(binary)) 150 | case common.EventAudio: 151 | blob := event.Data.(*common.BlobData) 152 | binary := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(blob.Binary)) 153 | segments = append(segments, onebot.NewRecord(binary)) 154 | case common.EventFile: 155 | // TODO: 156 | /* 157 | if oc.agent == LAGRANGE_ONEBOT { 158 | } 159 | */ 160 | blob := event.Data.(*common.BlobData) 161 | binary := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(blob.Binary)) 162 | segments = append(segments, onebot.NewFile(binary, blob.Name)) 163 | case common.EventLocation: 164 | location := event.Data.(*common.LocationData) 165 | locationJson := fmt.Sprintf(` 166 | { 167 | "app": "com.tencent.map", 168 | "desc": "地图", 169 | "view": "LocationShare", 170 | "ver": "0.0.0.1", 171 | "prompt": "[位置]%s", 172 | "from": 1, 173 | "meta": { 174 | "Location.Search": { 175 | "id": "12250896297164027526", 176 | "name": "%s", 177 | "address": "%s", 178 | "lat": "%.5f", 179 | "lng": "%.5f", 180 | "from": "plusPanel" 181 | } 182 | }, 183 | "config": { 184 | "forward": 1, 185 | "autosize": 1, 186 | "type": "card" 187 | } 188 | } 189 | `, location.Name, location.Name, location.Address, location.Latitude, location.Longitude) 190 | segments = append(segments, onebot.NewJSON(locationJson)) 191 | case common.EventRevoke: 192 | // TODO: 193 | default: 194 | return nil, fmt.Errorf("%s not support", event.Type) 195 | } 196 | 197 | var request *onebot.Request 198 | if event.Chat.Type == "private" { 199 | request = onebot.NewPrivateMsgRequest(targetID, segments) 200 | } else { 201 | request = onebot.NewGroupMsgRequest(targetID, segments) 202 | } 203 | 204 | if messageID, err := oc.sendMsg(request); err != nil { 205 | return nil, err 206 | } else { 207 | return &common.OctopusEvent{ 208 | ID: common.Itoa(messageID), 209 | Timestamp: time.Now().Unix(), 210 | }, nil 211 | } 212 | } 213 | 214 | func (oc *OnebotClient) Dispose() { 215 | oldConn := oc.conn 216 | if oldConn == nil { 217 | return 218 | } 219 | msg := websocket.FormatCloseMessage( 220 | websocket.CloseGoingAway, 221 | fmt.Sprintf(`{"type": %d, "data": {"type": %d, "data": "server_shutting_down"}}`, common.MsgRequest, common.ReqDisconnect), 222 | ) 223 | _ = oldConn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(3*time.Second)) 224 | _ = oldConn.Close() 225 | } 226 | 227 | func (oc *OnebotClient) processResponse(resp *onebot.Response) { 228 | log.Debugf("Receive response: %+v", resp) 229 | oc.websocketRequestsLock.RLock() 230 | respChan, ok := oc.websocketRequests[resp.Echo] 231 | oc.websocketRequestsLock.RUnlock() 232 | if ok { 233 | select { 234 | case respChan <- resp: 235 | default: 236 | log.Warnf("Failed to handle response to %s: channel didn't accept response", resp.Echo) 237 | } 238 | } else { 239 | log.Warnf("Dropping response to %s: unknown request ID", resp.Echo) 240 | } 241 | } 242 | 243 | func (oc *OnebotClient) processEvent(event onebot.IEvent) { 244 | log.Debugf("Receive event: %+v", event) 245 | 246 | key := oc.getEventKey(event) 247 | oc.mutex.LockKey(key) 248 | defer oc.mutex.UnlockKey(key) 249 | 250 | switch event.EventType() { 251 | case onebot.MessagePrivate: 252 | oc.processPrivateMessage(event.(*onebot.Message)) 253 | case onebot.MessageGroup: 254 | oc.processGroupMessage(event.(*onebot.Message)) 255 | case onebot.MetaLifecycle: 256 | oc.processMetaLifycycle(event.(*onebot.LifeCycle)) 257 | case onebot.NoticeOfflineFile: 258 | oc.processOfflineFile(event.(*onebot.OfflineFile)) 259 | case onebot.NoticeGroupUpload: 260 | oc.processGroupUpload(event.(*onebot.OfflineFile)) 261 | case onebot.NoticeGroupRecall: 262 | oc.processGroupRecall(event.(*onebot.GroupRecall)) 263 | case onebot.NoticeFriendRecall: 264 | oc.processFriendRecall(event.(*onebot.FriendRecall)) 265 | case onebot.MetaHeartbeat: 266 | log.Debugf("Receive heartbeat: %+v", event.(*onebot.Heartbeat).Status) 267 | } 268 | } 269 | 270 | func (oc *OnebotClient) getEventKey(event onebot.IEvent) string { 271 | switch event.EventType() { 272 | case onebot.MessagePrivate: 273 | m := event.(*onebot.Message) 274 | targetID := m.Sender.UserID 275 | if m.PostType == "message_sent" { // sent by self 276 | targetID = m.TargetID 277 | } 278 | return common.Itoa(targetID) 279 | case onebot.MessageGroup: 280 | return common.Itoa(event.(*onebot.Message).GroupID) 281 | case onebot.NoticeOfflineFile: 282 | return common.Itoa(event.(*onebot.OfflineFile).UserID) 283 | case onebot.NoticeGroupUpload: 284 | return common.Itoa(event.(*onebot.OfflineFile).GroupID) 285 | case onebot.NoticeGroupRecall: 286 | return common.Itoa(event.(*onebot.GroupRecall).GroupID) 287 | case onebot.NoticeFriendRecall: 288 | return common.Itoa(event.(*onebot.FriendRecall).UserID) 289 | } 290 | 291 | return "" 292 | } 293 | 294 | func (oc *OnebotClient) processPrivateMessage(m *onebot.Message) { 295 | segments := m.Message.([]onebot.ISegment) 296 | if len(segments) == 0 { 297 | return 298 | } 299 | 300 | event := oc.generateEvent(fmt.Sprint(m.MessageID), m.Time) 301 | 302 | targetID := m.Sender.UserID 303 | if m.PostType == "message_sent" { // sent by self 304 | targetID = m.TargetID 305 | } 306 | targetName := common.Itoa(targetID) 307 | if target, ok := oc.friends[targetID]; ok { 308 | targetName = cmp.Or(target.Remark, target.Nickname) 309 | } 310 | 311 | event.From = common.User{ 312 | ID: common.Itoa(m.Sender.UserID), 313 | Username: m.Sender.Nickname, 314 | Remark: m.Sender.Card, 315 | } 316 | event.Chat = common.Chat{ 317 | Type: "private", 318 | ID: common.Itoa(targetID), 319 | Title: targetName, 320 | } 321 | 322 | oc.processMessage(event, segments) 323 | } 324 | 325 | func (oc *OnebotClient) processGroupMessage(m *onebot.Message) { 326 | segments := m.Message.([]onebot.ISegment) 327 | if len(segments) == 0 { 328 | return 329 | } 330 | 331 | event := oc.generateEvent(fmt.Sprint(m.MessageID), m.Time) 332 | 333 | targetName := common.Itoa(m.GroupID) 334 | if target, ok := oc.groups[m.GroupID]; ok { 335 | targetName = target.Name 336 | } 337 | 338 | event.From = common.User{ 339 | ID: common.Itoa(m.Sender.UserID), 340 | Username: m.Sender.Nickname, 341 | Remark: m.Sender.Card, 342 | } 343 | event.Chat = common.Chat{ 344 | Type: "group", 345 | ID: common.Itoa(m.GroupID), 346 | Title: targetName, 347 | } 348 | 349 | oc.processMessage(event, m.Message.([]onebot.ISegment)) 350 | } 351 | 352 | func (oc *OnebotClient) processMessage(event *common.OctopusEvent, segments []onebot.ISegment) { 353 | event.Type = common.EventText 354 | 355 | var summary []string 356 | 357 | photos := []*common.BlobData{} 358 | for _, s := range segments { 359 | switch v := s.(type) { 360 | case *onebot.TextSegment: 361 | summary = append(summary, v.Content()) 362 | case *onebot.FaceSegment: 363 | summary = append(summary, fmt.Sprintf("/[Face%s]", v.ID())) 364 | case *onebot.MarketFaceSegment: 365 | summary = append(summary, v.Content()) 366 | if v.URL() != "" { 367 | if bin, err := common.Download(v.URL()); err != nil { 368 | log.Warnf("Download market face failed: %v", err) 369 | } else { 370 | event.Type = common.EventSticker 371 | event.Data = bin 372 | } 373 | } 374 | case *onebot.AtSegment: 375 | targetName := v.Target() 376 | 377 | groupID, _ := common.Atoi(event.Chat.ID) 378 | memberID, _ := common.Atoi(v.Target()) 379 | if member, err := oc.getGroupMemberInfo(groupID, memberID, false); err == nil { 380 | targetName = cmp.Or(member.Card, member.Nickname) 381 | } 382 | summary = append(summary, fmt.Sprintf("@%s ", targetName)) 383 | case *onebot.ImageSegment: 384 | summary = append(summary, "[图片]") 385 | if v.URL() == "" { 386 | if bin, err := oc.getMedia(onebot.GetImage, v.File()); err != nil { 387 | log.Warnf("Download image failed: %v", err) 388 | } else { 389 | photos = append(photos, bin) 390 | } 391 | } else { 392 | if bin, err := common.Download(v.URL()); err != nil { 393 | log.Warnf("Download image failed: %v", err) 394 | } else { 395 | bin.Name = v.File() 396 | photos = append(photos, bin) 397 | } 398 | } 399 | case *onebot.FileSegment: 400 | if bin, err := oc.getMedia(onebot.GetFile, v.FileID()); err != nil { 401 | log.Warnf("Download file failed: %v", err) 402 | event.Content = "[文件下载失败]" 403 | } else { 404 | event.Type = common.EventFile 405 | event.Data = bin 406 | } 407 | case *onebot.RecordSegment: 408 | if bin, err := oc.getMedia(onebot.GetRecord, v.File()); err != nil { 409 | log.Warnf("Download record failed: %v", err) 410 | event.Content = "[语音下载失败]" 411 | } else { 412 | event.Type = common.EventAudio 413 | event.Data = bin 414 | } 415 | case *onebot.VideoSegment: 416 | if v.URL() == "" { 417 | if bin, err := oc.getMedia(onebot.GetFile, v.FileID()); err != nil { 418 | log.Warnf("Download video by GetFile failed: %v", err) 419 | event.Content = "[视频下载失败]" 420 | } else { 421 | event.Type = common.EventVideo 422 | event.Data = bin 423 | } 424 | } else { 425 | if bin, err := common.Download(v.URL()); err != nil { 426 | log.Warnf("Download video by URL failed: %v", err) 427 | event.Content = "[视频下载失败]" 428 | } else { 429 | event.Type = common.EventVideo 430 | event.Data = bin 431 | } 432 | } 433 | case *onebot.ReplySegment: 434 | event.Reply = &common.ReplyInfo{ 435 | ID: v.ID(), 436 | Timestamp: 0, 437 | } 438 | case *onebot.ForwardSegment: 439 | event.Type = common.EventApp 440 | event.Data = oc.convertForward(v.ID()) 441 | case *onebot.JSONSegment: 442 | content := v.Content() 443 | view := gjson.Get(content, "view").String() 444 | if view == "LocationShare" { 445 | name := gjson.Get(content, "meta.*.name").String() 446 | address := gjson.Get(content, "meta.*.address").String() 447 | latitude := gjson.Get(content, "meta.*.lat").Float() 448 | longitude := gjson.Get(content, "meta.*.lng").Float() 449 | event.Type = common.EventLocation 450 | event.Data = &common.LocationData{ 451 | Name: name, 452 | Address: address, 453 | Longitude: longitude, 454 | Latitude: latitude, 455 | } 456 | } else { 457 | if url := gjson.Get(content, "meta.*.qqdocurl").String(); len(url) > 0 { 458 | title := gjson.Get(content, "meta.*.title").String() 459 | desc := gjson.Get(content, "meta.*.desc").String() 460 | prompt := gjson.Get(content, "prompt").String() 461 | event.Type = common.EventApp 462 | event.Data = &common.AppData{ 463 | Title: prompt, 464 | Description: desc, 465 | Source: title, 466 | URL: url, 467 | } 468 | } else if jumpUrl := gjson.Get(content, "meta.*.jumpUrl").String(); len(jumpUrl) > 0 { 469 | //title := gjson.Get(v.Content, "meta.*.title").String() 470 | desc := gjson.Get(content, "meta.*.desc").String() 471 | prompt := gjson.Get(content, "prompt").String() 472 | tag := gjson.Get(content, "meta.*.tag").String() 473 | event.Type = common.EventApp 474 | event.Data = &common.AppData{ 475 | Title: prompt, 476 | Description: desc, 477 | Source: tag, 478 | URL: jumpUrl, 479 | } 480 | } 481 | } 482 | default: 483 | summary = append(summary, fmt.Sprintf("[%v]", s.SegmentType())) 484 | } 485 | } 486 | 487 | if len(summary) > 0 { 488 | if len(summary) == 1 && segments[0].SegmentType() == onebot.Image { 489 | event.Type = common.EventPhoto 490 | event.Data = photos 491 | 492 | if segments[0].(*onebot.ImageSegment).IsSticker { 493 | event.Type = common.EventSticker 494 | event.Data = photos[0] 495 | } 496 | } else { 497 | event.Content = strings.Join(summary, "") 498 | 499 | if len(photos) > 0 { 500 | event.Type = common.EventPhoto 501 | event.Data = photos 502 | } 503 | } 504 | } 505 | 506 | oc.pushEvent(event) 507 | } 508 | 509 | func (oc *OnebotClient) processMetaLifycycle(m *onebot.LifeCycle) { 510 | if m.SubType == "connect" { 511 | time.Sleep(time.Minute) 512 | oc.updateChats() 513 | } 514 | } 515 | 516 | func (oc *OnebotClient) processOfflineFile(m *onebot.OfflineFile) { 517 | // FIXME: can't get message id 518 | event := oc.generateEvent(fmt.Sprint(time.Now().Unix()), time.Now().UnixMilli()) 519 | 520 | targetID := m.UserID 521 | targetName := common.Itoa(targetID) 522 | if target, ok := oc.friends[targetID]; ok { 523 | targetName = cmp.Or(target.Remark, target.Nickname) 524 | } 525 | 526 | event.From = common.User{ 527 | ID: common.Itoa(targetID), 528 | Username: targetName, 529 | Remark: targetName, 530 | } 531 | event.Chat = common.Chat{ 532 | Type: "private", 533 | ID: common.Itoa(targetID), 534 | Title: targetName, 535 | } 536 | 537 | if bin, err := common.Download(m.File.URL); err != nil { 538 | log.Warnf("Download file failed: %v", err) 539 | event.Content = "[文件下载失败]" 540 | } else { 541 | bin.Name = m.File.Name 542 | event.Type = common.EventFile 543 | event.Data = bin 544 | } 545 | 546 | oc.pushEvent(event) 547 | } 548 | 549 | func (oc *OnebotClient) processGroupUpload(m *onebot.OfflineFile) { 550 | // FIXME: can't get message id 551 | event := oc.generateEvent(fmt.Sprint(time.Now().Unix()), time.Now().UnixMilli()) 552 | 553 | groupName := common.Itoa(m.GroupID) 554 | if group, ok := oc.groups[m.GroupID]; ok { 555 | groupName = group.Name 556 | } 557 | 558 | targetName := common.Itoa(m.UserID) 559 | if member, err := oc.getGroupMemberInfo(m.GroupID, m.UserID, false); err == nil { 560 | targetName = cmp.Or(member.Card, member.Nickname) 561 | } 562 | 563 | event.From = common.User{ 564 | ID: common.Itoa(m.UserID), 565 | Username: targetName, 566 | Remark: targetName, 567 | } 568 | event.Chat = common.Chat{ 569 | Type: "group", 570 | ID: common.Itoa(m.GroupID), 571 | Title: groupName, 572 | } 573 | 574 | if bin, err := common.Download(m.File.URL); err != nil { 575 | log.Warnf("Download file failed: %v", err) 576 | event.Content = "[文件下载失败]" 577 | } else { 578 | bin.Name = m.File.Name 579 | event.Type = common.EventFile 580 | event.Data = bin 581 | } 582 | 583 | oc.pushEvent(event) 584 | } 585 | 586 | func (oc *OnebotClient) processGroupRecall(m *onebot.GroupRecall) { 587 | event := oc.generateEvent(fmt.Sprint(time.Now().Unix()), time.Now().UnixMilli()) 588 | 589 | groupName := common.Itoa(m.GroupID) 590 | if group, ok := oc.groups[m.GroupID]; ok { 591 | groupName = group.Name 592 | } 593 | 594 | targetName := common.Itoa(m.OperatorID) 595 | if member, err := oc.getGroupMemberInfo(m.GroupID, m.OperatorID, false); err == nil { 596 | targetName = cmp.Or(member.Card, member.Nickname) 597 | } 598 | 599 | event.From = common.User{ 600 | ID: common.Itoa(m.OperatorID), 601 | Username: targetName, 602 | Remark: targetName, 603 | } 604 | event.Chat = common.Chat{ 605 | Type: "group", 606 | ID: common.Itoa(m.GroupID), 607 | Title: groupName, 608 | } 609 | 610 | event.Type = common.EventRevoke 611 | event.Content = "recalled a message" 612 | 613 | event.Reply = &common.ReplyInfo{ 614 | ID: common.Itoa(m.MessageID), 615 | Timestamp: 0, 616 | Sender: targetName, 617 | } 618 | 619 | oc.pushEvent(event) 620 | } 621 | 622 | func (oc *OnebotClient) processFriendRecall(m *onebot.FriendRecall) { 623 | event := oc.generateEvent(fmt.Sprint(time.Now().Unix()), time.Now().UnixMilli()) 624 | 625 | if m.UserID == oc.self.ID { // recall self 626 | log.Infof("Failed to recall self sent private message #%d", m.MessageID) 627 | return 628 | } 629 | 630 | targetID := m.UserID 631 | targetName := common.Itoa(targetID) 632 | if target, ok := oc.friends[targetID]; ok { 633 | targetName = cmp.Or(target.Remark, target.Nickname) 634 | } 635 | 636 | event.From = common.User{ 637 | ID: common.Itoa(targetID), 638 | Username: targetName, 639 | Remark: targetName, 640 | } 641 | event.Chat = common.Chat{ 642 | Type: "private", 643 | ID: common.Itoa(targetID), 644 | Title: targetName, 645 | } 646 | 647 | event.Type = common.EventRevoke 648 | event.Content = "recalled a message" 649 | 650 | event.Reply = &common.ReplyInfo{ 651 | ID: common.Itoa(m.MessageID), 652 | Timestamp: 0, 653 | Sender: targetName, 654 | } 655 | 656 | oc.pushEvent(event) 657 | } 658 | 659 | func (oc *OnebotClient) getGroupMemberInfo(groupID, userID int64, noCache bool) (*onebot.Sender, error) { 660 | resp, err := oc.request(onebot.NewGetGroupMemberInfoRequest(groupID, userID, noCache)) 661 | if err == nil { 662 | var s onebot.Sender 663 | err = mapstructure.WeakDecode(resp, &s) 664 | return &s, err 665 | } 666 | 667 | return nil, err 668 | } 669 | 670 | func (oc *OnebotClient) getMedia(t onebot.RequestType, file string) (*common.BlobData, error) { 671 | var request *onebot.Request 672 | switch t { 673 | case onebot.GetRecord: 674 | request = onebot.NewGetRecordRequest(file) 675 | case onebot.GetImage: 676 | request = onebot.NewGetImageRequest(file) 677 | case onebot.GetFile: 678 | request = onebot.NewGetFileRequest(file) 679 | default: 680 | return nil, fmt.Errorf("request type not support: %+v", t) 681 | } 682 | 683 | count := 0 684 | 685 | for { 686 | count += 1 687 | resp, err := oc.request(request) 688 | if err == nil { 689 | var f onebot.FileInfo 690 | if err = mapstructure.WeakDecode(resp, &f); err != nil { 691 | return nil, err 692 | } 693 | 694 | if f.Base64 != "" { 695 | var data []byte 696 | if data, err = base64.StdEncoding.DecodeString(f.Base64); err == nil { 697 | return &common.BlobData{ 698 | Name: f.FileName, 699 | Mime: mimetype.Detect(data).String(), 700 | Binary: data, 701 | }, nil 702 | } 703 | } else { 704 | var bin *common.BlobData 705 | if bin, err = common.Download(f.URL); err == nil { 706 | bin.Name = f.FileName 707 | return bin, nil 708 | } 709 | } 710 | } 711 | 712 | if count > 3 { 713 | return nil, err 714 | } 715 | time.Sleep(3 * time.Second) 716 | } 717 | } 718 | 719 | func (oc *OnebotClient) getMsg(id int32) (*onebot.BareMessage, error) { 720 | resp, err := oc.request(onebot.NewGetMsgRequest(id)) 721 | if err == nil { 722 | var message onebot.BareMessage 723 | err = mapstructure.WeakDecode(resp, &message) 724 | return &message, err 725 | } 726 | 727 | return nil, err 728 | } 729 | 730 | func (oc *OnebotClient) forwardFriendSingleMsg(userID int64, messageID int32) error { 731 | _, err := oc.request(onebot.NewPrivateForwardRequest(userID, messageID)) 732 | return err 733 | } 734 | 735 | func (oc *OnebotClient) forwardGroupSingleMsg(groupID int64, messageID int32) error { 736 | _, err := oc.request(onebot.NewGroupForwardRequest(groupID, messageID)) 737 | return err 738 | } 739 | 740 | func (oc *OnebotClient) getForwardMsg(id string) ([]*onebot.Message, error) { 741 | resp, err := oc.request(onebot.NewGetForwardMsgRequest(id)) 742 | if err == nil { 743 | messages := []*onebot.Message{} 744 | msgs := resp.(map[string]interface{})["messages"] 745 | for _, msg := range msgs.([]interface{}) { 746 | if message, err := onebot.UnmarshalPayload(msg.(map[string]interface{})); err == nil { 747 | messages = append(messages, message.(*onebot.Message)) 748 | } 749 | } 750 | return messages, err 751 | } 752 | return nil, err 753 | } 754 | 755 | func (oc *OnebotClient) sendMsg(request *onebot.Request) (int64, error) { 756 | resp, err := oc.request(request) 757 | if err == nil { 758 | return int64(resp.(map[string]interface{})["message_id"].(float64)), nil 759 | } 760 | return 0, err 761 | } 762 | 763 | // Lagrange.OneBot 764 | func (oc *OnebotClient) uploadPrivateFile(userID int64, file string, name string) error { 765 | _, err := oc.request(onebot.NewUploadPrivateFileRequest(userID, file, name)) 766 | return err 767 | } 768 | 769 | // Lagrange.OneBot 770 | func (oc *OnebotClient) uploadGroupFile(groupID int64, file string, name string) error { 771 | _, err := oc.request(onebot.NewUploadGroupFileRequest(groupID, file, name)) 772 | return err 773 | } 774 | 775 | func (oc *OnebotClient) convertForward(id string) *common.AppData { 776 | var summary []string 777 | var content []string 778 | var blobs = map[string]*common.BlobData{} 779 | 780 | var handleForward func(level int, nodes []*onebot.Message) 781 | handleForward = func(level int, nodes []*onebot.Message) { 782 | summary = append(summary, "ForwardMessage:\n") 783 | if level > 0 { 784 | content = append(content, "
") 785 | } 786 | 787 | for _, node := range nodes { 788 | name := cmp.Or(node.Sender.Card, node.Sender.Nickname, fmt.Sprint(node.Sender.UserID)) 789 | 790 | summary = append(summary, fmt.Sprintf("%s:\n", name)) 791 | content = append(content, fmt.Sprintf("%s:

", name)) 792 | for _, s := range node.Message.([]onebot.ISegment) { 793 | switch v := s.(type) { 794 | case *onebot.TextSegment: 795 | summary = append(summary, v.Content()) 796 | content = append(content, v.Content()) 797 | case *onebot.FaceSegment: 798 | summary = append(summary, fmt.Sprintf("/[Face%s]", v.ID())) 799 | content = append(content, fmt.Sprintf("/[Face%s]", v.ID())) 800 | case *onebot.AtSegment: 801 | summary = append(summary, fmt.Sprintf("@%s ", v.Target())) 802 | content = append(content, fmt.Sprintf("@%s ", v.Target())) 803 | case *onebot.ImageSegment: 804 | summary = append(summary, "[图片]") 805 | 806 | var bin *common.BlobData 807 | var err error 808 | if v.URL() == "" { 809 | bin, err = oc.getMedia(onebot.GetImage, v.File()) 810 | } else { 811 | bin, err = common.Download(v.URL()) 812 | } 813 | if err != nil { 814 | log.Warnf("Download image failed: %v", err) 815 | content = append(content, "[图片]") 816 | } else { 817 | bin.Name = v.File() 818 | blobs[v.File()] = bin 819 | content = append(content, fmt.Sprintf("", common.REMOTE_PREFIX, v.File())) 820 | } 821 | case *onebot.ForwardSegment: 822 | if messages, err := oc.getForwardMsg(v.ID()); err == nil { 823 | handleForward(level+1, messages) 824 | } else { 825 | log.Warnf("Failed to get forward #%s", v.ID()) 826 | summary = append(summary, "[转发]") 827 | content = append(content, "[转发]") 828 | } 829 | default: 830 | summary = append(summary, fmt.Sprintf("[%v]", s.SegmentType())) 831 | content = append(content, fmt.Sprintf("[%v]", s.SegmentType())) 832 | } 833 | } 834 | summary = append(summary, "\n") 835 | content = append(content, "

") 836 | } 837 | 838 | if level > 0 { 839 | content = append(content, "
") 840 | } 841 | } 842 | 843 | if messages, err := oc.getForwardMsg(id); err == nil { 844 | handleForward(0, messages) 845 | } else { 846 | log.Warnf("Failed to get forward #%s", id) 847 | } 848 | 849 | return &common.AppData{ 850 | Title: fmt.Sprintf("[聊天记录 %s]", id), 851 | Description: strings.Join(summary, ""), 852 | Content: strings.Join(content, ""), 853 | Blobs: blobs, 854 | } 855 | } 856 | 857 | func (oc *OnebotClient) updateChats() { 858 | if resp, err := oc.request(onebot.NewGetFriendListRequest()); err == nil { 859 | friends := map[int64]*onebot.FriendInfo{} 860 | 861 | for _, friend := range resp.([]interface{}) { 862 | var f onebot.FriendInfo 863 | if err := mapstructure.WeakDecode(friend.(map[string]interface{}), &f); err != nil { 864 | continue 865 | } 866 | friends[f.ID] = &f 867 | } 868 | 869 | oc.friends = friends 870 | } 871 | 872 | if resp, err := oc.request(onebot.NewGetLoginInfoRequest()); err == nil { 873 | var f onebot.FriendInfo 874 | if err := mapstructure.WeakDecode(resp.(map[string]interface{}), &f); err == nil { 875 | oc.self = &f 876 | oc.friends[f.ID] = &f 877 | } 878 | } 879 | 880 | if resp, err := oc.request(onebot.NewGetGroupListRequest()); err == nil { 881 | groups := map[int64]*onebot.GroupInfo{} 882 | 883 | for _, group := range resp.([]interface{}) { 884 | var g onebot.GroupInfo 885 | if err := mapstructure.WeakDecode(group.(map[string]interface{}), &g); err != nil { 886 | continue 887 | } 888 | groups[g.ID] = &g 889 | } 890 | 891 | oc.groups = groups 892 | } 893 | 894 | // Sync chats 895 | event := oc.generateEvent("sync", time.Now().UnixMilli()) 896 | 897 | chats := []*common.Chat{} 898 | 899 | for _, f := range oc.friends { 900 | chats = append(chats, &common.Chat{ 901 | ID: common.Itoa(f.ID), 902 | Type: "private", 903 | Title: cmp.Or(f.Remark, f.Nickname), 904 | }) 905 | } 906 | for _, g := range oc.groups { 907 | chats = append(chats, &common.Chat{ 908 | ID: common.Itoa(g.ID), 909 | Type: "group", 910 | Title: g.Name, 911 | }) 912 | } 913 | 914 | event.Type = common.EventSync 915 | event.Data = chats 916 | 917 | oc.pushEvent(event) 918 | } 919 | 920 | func (oc *OnebotClient) generateEvent(id string, ts int64) *common.OctopusEvent { 921 | return &common.OctopusEvent{ 922 | Vendor: *oc.vendor, 923 | ID: id, 924 | Timestamp: ts, 925 | } 926 | } 927 | 928 | func (oc *OnebotClient) pushEvent(event *common.OctopusEvent) { 929 | event = oc.s2m.Apply(event) 930 | 931 | oc.out <- event 932 | } 933 | 934 | func (oc *OnebotClient) request(req *onebot.Request) (any, error) { 935 | ctx, cancel := context.WithTimeout(context.Background(), oc.config.Service.SendTiemout) 936 | defer cancel() 937 | 938 | req.Echo = fmt.Sprint(atomic.AddInt64(&oc.websocketRequestID, 1)) 939 | 940 | respChan := make(chan *onebot.Response, 1) 941 | 942 | oc.addWebsocketResponseWaiter(req.Echo, respChan) 943 | defer oc.removeWebsocketResponseWaiter(req.Echo, respChan) 944 | 945 | log.Debugf("Send request message #%s %s %+v", req.Echo, req.Action, req) 946 | if err := oc.sendMessage(req); err != nil { 947 | return nil, err 948 | } 949 | 950 | select { 951 | case resp := <-respChan: 952 | if resp.Status != "ok" { 953 | return resp, fmt.Errorf("%s response retcode: %d", resp.Status, resp.Retcode) 954 | } else { 955 | return resp.Data, nil 956 | } 957 | case <-ctx.Done(): 958 | return nil, ctx.Err() 959 | } 960 | } 961 | 962 | func (oc *OnebotClient) sendMessage(msg *onebot.Request) error { 963 | conn := oc.conn 964 | if msg == nil { 965 | return nil 966 | } else if conn == nil { 967 | return ErrWebsocketNotConnected 968 | } 969 | oc.writeLock.Lock() 970 | defer oc.writeLock.Unlock() 971 | _ = conn.SetWriteDeadline(time.Now().Add(oc.config.Service.SendTiemout)) 972 | return conn.WriteJSON(msg) 973 | } 974 | 975 | func (oc *OnebotClient) addWebsocketResponseWaiter(echo string, waiter chan<- *onebot.Response) { 976 | oc.websocketRequestsLock.Lock() 977 | oc.websocketRequests[echo] = waiter 978 | oc.websocketRequestsLock.Unlock() 979 | } 980 | 981 | func (oc *OnebotClient) removeWebsocketResponseWaiter(echo string, waiter chan<- *onebot.Response) { 982 | oc.websocketRequestsLock.Lock() 983 | existingWaiter, ok := oc.websocketRequests[echo] 984 | if ok && existingWaiter == waiter { 985 | delete(oc.websocketRequests, echo) 986 | } 987 | oc.websocketRequestsLock.Unlock() 988 | close(waiter) 989 | } 990 | --------------------------------------------------------------------------------