Compare commits

..

18 commits

Author SHA1 Message Date
9455220a0b Переписал Dockerfile, теперь образы контейнеров весят меньше 2026-01-01 21:24:40 +05:00
4f998a2730 Добавил настройку отображения 2025-05-31 19:24:58 +05:00
9fa91004bf Merge pull request 'multi_feed' (#6) from multi_feed into master
Reviewed-on: #6
2025-05-12 12:21:31 +05:00
0373a1addd Лишние пробелы в docker compose 2025-05-04 16:23:30 +05:00
a79933b724 Исправлена ошибка, при которой после таймаута функция NewsText ничего не возвращала и программа падала. Так же теперь лента читается начиная от самого старого поста, заканчивая самым новым. 2025-05-04 16:14:34 +05:00
47242e5431 Мультилента, тестируется. Потом документировать 2025-04-30 15:59:44 +05:00
66afe8eac7 Добавлено отслеживание изменений в конфиге. Посмотрим как будет работать 2025-04-30 10:19:33 +05:00
1de14cdae1 Сделал Makefile 2025-04-23 21:53:08 +05:00
4b7b941650 Добавлены README и лицензия 2025-04-23 19:39:16 +05:00
13e06eea4b Merge pull request 'add_redis' (#2) from add_redis into master
Reviewed-on: #2
2025-04-23 17:51:50 +05:00
c4fb075a83 Merge pull request 'img_extractor' (#1) from img_extractor into master
Reviewed-on: #1
2025-04-23 17:50:25 +05:00
10474bb165 Забыл добавить переменную для NSFW... 2025-04-23 09:57:44 +05:00
4d38338506 Теперь тэг <p> тоже делает перевод на новую строку... 2025-04-23 09:19:25 +05:00
664bc8df76 Обновил пример конфига 2025-04-23 09:10:18 +05:00
fef2d2fdb6 Добавил включение/отключение NSFW штук 2025-04-23 09:09:01 +05:00
1f1c964e91 Добавил зависимость 2025-04-21 23:22:25 +05:00
c853cfb592 Переворошил файлы (опять), закинул всё в докер 2025-04-21 23:13:46 +05:00
be76c08756 Добавил Redis в качестве стека 2025-04-14 13:41:04 +05:00
18 changed files with 711 additions and 301 deletions

3
.gitignore vendored
View file

@ -18,7 +18,8 @@ config.yaml
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
kiki
redis_data
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM golang:1.24-alpine AS build
RUN apk add --no-cache make
WORKDIR /app/kiki
RUN mkdir /app/kiki/config /app/kiki/service /app/kiki/stacker /app/kiki/tooter /app/kiki/file_watcher
COPY config/* /app/kiki/config
COPY service/* /app/kiki/service
COPY stacker/* /app/kiki/stacker
COPY tooter/* /app/kiki/tooter
COPY file_watcher/* /app/kiki/file_watcher
COPY Makefile /app/kiki/
RUN make
FROM alpine:latest
WORKDIR /app/kiki/
COPY --from=build /app/kiki/kiki /app/kiki/kiki
RUN chmod +x /app/kiki/kiki
CMD [ "/app/kiki/kiki", "run" ]

13
LICENSE.md Normal file
View file

@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

15
Makefile Normal file
View file

@ -0,0 +1,15 @@
kiki : tooter/go.sum config/go.sum stacker/go.sum service/go.sum service/kiki
mv service/kiki kiki
tooter/go.sum : tooter/go.mod
cd tooter && go mod tidy && cd ../
config/go.sum : config/go.mod
cd config && go mod tidy && cd ../
stacker/go.sum : stacker/go.mod
cd stacker && go mod tidy && cd ../
service/go.sum : service/go.mod
cd service && go mod tidy && cd ../
service/kiki : service/kiki.go
go build -C service
clean :
rm kiki tooter/go.sum config/go.sum stacker/go.sum service/go.sum

88
README.md Normal file
View file

@ -0,0 +1,88 @@
# Kiki - RSS -> MastodonAPI crossposter
Данная программа представляет собой кросспостер из лент RSS в Mastodon-совместимые сервера. Увы, программа пока тестировалась только на Pleroma
### ⚠ ПРОВЕРКИ НА КОЛИЧЕСТВО ПОДДЕРЖИВАЕМЫХ СЕРВЕРОМ СИМВОЛОВ НЕТ, НЕИЗВЕСТНО КАК ПРОГРАММА СЕБЯ ПОВЕДЕТ ЕСЛИ В НОВОСТИ 500+ СИМВОЛОВ
# Руководство по сборке и использованию
Для начала проверьте зависимости:
* golang 1.24.2+
* redis
* gnu make
После необходимо произвести сборку утилиты с помощью команды ```make```:
```
make
```
В папке с проектом появится исполняемый файл kiki. Перед запуском необходимо заполнить конфигурационный файл config.yaml (пример заполнения представлен в файле config.example.yaml)
```
instance: https://pleroma.catgirls.asia #Адрес инстанса
rss_urls: #YAML массив лент
- url: https://habr.com/ru/rss/flows/admin/articles/?fl=ru #Адрес ленты
sensitive: false #Нужно ли ставить NSFW плашку
visibility: "unlisted" #Режим отображения постов. Есть public, unlisted, private, direct
- url: https://4pda.to/feed
sensitive: false
visibility: "unlisted"
redis:
address: localhost:6379 #Адрес Redis, в случае использования Docker Compose необходимо написать redis:6379
```
После чего необходимо провести инициализацию для получения секретов аккаунта. **СЕКРЕТЫ ХРАНЯТСЯ В ОТКРЫТОМ ВИДЕ, ТАК ЧТО БУДЬТЕ ОСТОРОЖНЫ!**
```
./kiki init
```
Программа в коммандной строке выдаст ссылку на получение прав. Необходимо по ней перейти, дать разрешения, скопировать OAuth код, вставить в коммандную строку и нажать Enter. После этого должен создаться файл secret.conf.
После этого программу можно запустить командой ```./kiki run```. Перед запуском убедитесь что Redis запущен.
Если вам надо удалить утилиту и почистить все go.sum файлы, то можно выполнить следующую команду:
```
make clean
```
# Запуск в Docker
Для запуска программы в Docker необходимо создать два файла: secret.conf и config.yaml:
```
touch secret.conf
touch config.yaml
```
Далее необходимо собрать Docker образ с утилитой:
```
docker compose build
```
После этого необходимо провести инициализацию:
```
docker compose run --rm kiki /app/kiki/kiki init
```
Программа в коммандной строке выдаст ссылку на получение прав. Необходимо по ней перейти, дать разрешения, скопировать OAuth код, вставить в коммандную строку и нажать Enter. В файле secret.conf должны появиться данные для использования аккаунта. Проверьте их наличие с помощью команды ```cat secret.conf```
После чего можно запустить контейнер с программой:
```
docker compose up -d
```
# TODO:
* [x] Первая реализация
* [x] Добавление картинок в пост
* [x] Добавление поддержки Redis
* [x] Упаковка в Docker образ
* [x] Создание Makefile для более удобной сборки
* [x] Поддержка обновления данных из конфига "на лету"
* [x] Добавление поддержки нескольких лент
* [ ] Добавить поддержку шаблонов
* [ ] Добавить сбор информации о инстансе, кол-ве поддерживаемых символов и кол-ве поддерживаемых медиа в одном посте. Сделать обработку этой информации и формирование постов с учетом инстансоспецифичных факторов
* [ ] Некоторые RSS-ленты содержат видео. Добавить поддержку видео в постах
* [ ] Дополнительные флаги для команды чтобы указывать где лежит конфиг
# Отказ от ответственности
Программное обеспечение поставляется "как есть" и автор не несет ответственности на нарушение работы ЭВМ

View file

@ -1,2 +1,10 @@
instance: https://pleroma.catgirls.asia instance: https://pleroma.catgirls.asia
rss_url: https://4pda.to/feed rss_urls:
- url: https://habr.com/ru/rss/flows/admin/articles/?fl=ru
sensitive: false
visibility: "unlisted"
- url: https://4pda.to/feed
sensitive: false
visibility: "unlisted"
redis:
address: localhost:6379

71
config/config.go Normal file
View file

@ -0,0 +1,71 @@
package config
import (
"log"
"os"
"github.com/mattn/go-mastodon"
"gopkg.in/yaml.v2"
)
// Сруктура config.yaml
type KikiSettings struct {
Instance string `yaml:"instance,omitempty"`
RSSURLs []struct {
Url string `yaml:"url,omitempty"`
Sensitive bool `yaml:"sensitive,omitempty"`
Visibility string `yaml:"visibility"`
} `yaml:"rss_urls,omitempty"`
Redis struct {
Address string `yaml:"address"`
} `yaml:"redis"`
}
// Структура secret.conf
type MastodonClientData struct {
ClientID string `yaml:"clientID,omitempty"`
ClientSecret string `yaml:"clientSecret,omitempty"`
AccessToken string `yaml:"accessToken,omitempty"`
Instance string `yaml:"instance,omitempty"`
}
// Получение данных из конфига config.yaml
func GetKikiConfig(path string) KikiSettings {
var kikiSettings KikiSettings
kikiConfigFile, err := os.ReadFile(path)
if err != nil {
log.Println(err)
}
err = yaml.Unmarshal(kikiConfigFile, &kikiSettings)
if err != nil {
log.Println(err)
}
return kikiSettings
}
// Получение данных из конфига secret.conf
func GetSecrets(path string) *mastodon.Config {
var clientData MastodonClientData
secretConfig, err := os.ReadFile(path)
if err != nil {
log.Println(err)
}
err = yaml.Unmarshal(secretConfig, &clientData)
if err != nil {
log.Println(err)
}
config := &mastodon.Config{
Server: clientData.Instance,
ClientID: clientData.ClientID,
ClientSecret: clientData.ClientSecret,
AccessToken: clientData.AccessToken,
}
return config
}

14
config/go.mod Normal file
View file

@ -0,0 +1,14 @@
module kiki/config
go 1.24.2
require (
github.com/mattn/go-mastodon v0.0.9
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/gorilla/websocket v1.5.1 // indirect
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect
golang.org/x/net v0.25.0 // indirect
)

14
docker-compose.yml Normal file
View file

@ -0,0 +1,14 @@
services:
kiki:
image: git.catgirls.asia/b4d_us3r/kiki:latest
restart: always
volumes:
- ./config.yaml:/app/kiki/config.yaml
- ./secret.conf:/app/kiki/secret.conf
depends_on:
- redis
redis:
image: redis
restart: always
volumes:
- ./redis_data:/data

View file

@ -0,0 +1,32 @@
package file_watcher
import (
"os"
"time"
)
// Проверка на изменение в файле filename
func IsFileChange(lastMod *time.Time, filename string) bool {
fileStat, err := os.Stat(filename)
if err != nil {
return false
}
if *lastMod != fileStat.ModTime() {
*lastMod = fileStat.ModTime()
return true
} else {
return false
}
}
// Получение информации о том, когда файл изменился
func GetFileModTime(filename string) (time.Time, error) {
fileStat, err := os.Stat(filename)
if err != nil {
return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), err
}
return fileStat.ModTime(), nil
}

3
file_watcher/go.mod Normal file
View file

@ -0,0 +1,3 @@
module file_watcher
go 1.24.2

287
main.go
View file

@ -1,287 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-yaml/yaml"
"github.com/mattn/go-mastodon"
"github.com/mmcdole/gofeed"
"github.com/urfave/cli/v3"
"golang.org/x/net/html"
)
type MastodonClientData struct {
ClientID string `yaml:"clientID,omitempty"`
ClientSecret string `yaml:"clientSecret,omitempty"`
AccessToken string `yaml:"accessToken,omitempty"`
Instance string `yaml:"instance,omitempty"`
}
type KikiSettings struct {
Instance string `yaml:"instance,omitempty"`
RSSUri string `yaml:"rss_url,omitempty"`
}
func getSecrets(path string) *mastodon.Config {
var clientData MastodonClientData
secretConfig, err := os.ReadFile(path)
if err != nil {
log.Println(err)
}
err = yaml.Unmarshal(secretConfig, &clientData)
if err != nil {
log.Println(err)
}
config := &mastodon.Config{
Server: clientData.Instance,
ClientID: clientData.ClientID,
ClientSecret: clientData.ClientSecret,
AccessToken: clientData.AccessToken,
}
return config
}
func getKikiConfig(path string) KikiSettings {
var kikiSettings KikiSettings
kikiConfigFile, err := os.ReadFile(path)
if err != nil {
log.Println(err)
}
err = yaml.Unmarshal(kikiConfigFile, &kikiSettings)
if err != nil {
log.Println(err)
}
return kikiSettings
}
func clientConfiguration(Instance string) {
appConfig := &mastodon.AppConfig{
Server: Instance,
ClientName: "Kiki",
Scopes: "read write follow",
Website: "catgirls.asia",
RedirectURIs: "urn:ietf:wg:oauth:2.0:oob",
}
app, err := mastodon.RegisterApp(context.Background(), appConfig)
if err != nil {
log.Println(err)
}
u, err := url.Parse(app.AuthURI)
if err != nil {
log.Println(err)
}
var userToken string
fmt.Println(u)
fmt.Scanln(&userToken)
config := &mastodon.Config{
Server: Instance,
ClientID: app.ClientID,
ClientSecret: app.ClientSecret,
AccessToken: userToken,
}
mastoClient := mastodon.NewClient(config)
err = mastoClient.AuthenticateToken(context.Background(), userToken, "urn:ietf:wg:oauth:2.0:oob")
if err != nil {
log.Println(err)
}
clientData := MastodonClientData{
Instance: Instance,
ClientID: mastoClient.Config.ClientID,
ClientSecret: mastoClient.Config.ClientSecret,
AccessToken: mastoClient.Config.AccessToken,
}
marshaledYaml, err := yaml.Marshal(clientData)
if err != nil {
log.Println(err)
}
log.Println(string(marshaledYaml))
secretConfig, err := os.OpenFile("secret.conf", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
log.Println(err)
}
secretConfig.Write(marshaledYaml)
defer secretConfig.Close()
}
func newsText(url string) []*gofeed.Item {
fp := gofeed.NewParser()
feed, err := fp.ParseURL(url)
if err != nil {
log.Println(err)
}
log.Println("RSS лента получена")
return feed.Items
}
func createPost(mastoClient mastodon.Client, toot mastodon.Toot) {
_, err := mastoClient.PostStatus(context.Background(), &toot)
if err != nil {
log.Println(err)
}
}
func picBytesArray(picturesArray []string) [][]byte {
var picturesBytes [][]byte
for _, picture := range picturesArray {
resp, err := http.Get(picture)
if err != nil {
log.Println(err)
return picturesBytes
}
defer resp.Body.Close()
picBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Println(err)
return picturesBytes
}
picturesBytes = append(picturesBytes, picBytes)
}
return picturesBytes
}
func uploadPictures(mastoClient mastodon.Client, filesBytes [][]byte) []*mastodon.Attachment {
var attachments []*mastodon.Attachment
for _, file := range filesBytes {
att, err := mastoClient.UploadMediaFromBytes(context.Background(), file)
if err != nil {
log.Println(err)
return attachments
}
attachments = append(attachments, att)
}
return attachments
}
func createToot(mastoClient mastodon.Client, newsDesc string) (mastodon.Toot, error) {
var tootText string
var imgArray []string
var attachments []*mastodon.Attachment
toot := mastodon.Toot{
Visibility: "unlisted",
Sensitive: true,
}
uString := html.UnescapeString(newsDesc)
pHtml, err := html.Parse(strings.NewReader(uString))
if err != nil {
return mastodon.Toot{}, err
}
for n := range pHtml.Descendants() {
if n.Type != html.ElementNode {
tootText += (n.Data + "\n")
}
if n.Type == html.ElementNode && n.Data == "img" {
for _, attr := range n.Attr {
if attr.Key == "src" {
imgArray = append(imgArray, attr.Val)
}
}
}
}
if len(imgArray) != 0 {
attachments = uploadPictures(mastoClient, picBytesArray(imgArray))
}
toot.Status = tootText
for _, attach := range attachments {
toot.MediaIDs = append(toot.MediaIDs, attach.ID)
}
return toot, nil
}
func main() {
cmd := &cli.Command{
Name: "kiki",
Usage: "Ретранслятор из RSS в Mastodon. Когда-нибудь...",
Commands: []*cli.Command{
{
Name: "init",
Usage: "Инициализировать клиента",
Action: func(ctx context.Context, cmd *cli.Command) error {
confFile, err := filepath.Abs("config.yaml")
kikiConfig := getKikiConfig(confFile)
if err != nil {
log.Println(err)
}
instanceUrlParser, err := url.Parse(kikiConfig.Instance)
if err != nil {
log.Println(err)
}
instanceUrlParser.Scheme = "https"
clientConfiguration(instanceUrlParser.String())
return nil
},
},
{
Name: "run",
Usage: "Запуск транслятора",
Action: func(ctx context.Context, cmd *cli.Command) error {
var lastGUID string
mastoClient := mastodon.NewClient(getSecrets("secret.conf"))
confFile, err := filepath.Abs("config.yaml")
if err != nil {
log.Println(err)
}
kikiConfig := getKikiConfig(confFile)
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
news := newsText(kikiConfig.RSSUri)
if news[0].GUID != lastGUID {
log.Println(news[0].Description)
toot, err := createToot(*mastoClient, news[0].Description)
if err != nil {
log.Println(err)
}
createPost(*mastoClient, toot)
lastGUID = news[0].GUID
}
}
return nil
},
},
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}

38
service/go.mod Normal file
View file

@ -0,0 +1,38 @@
module kiki/kiki
go 1.24.2
replace kiki/config => ../config
replace kiki/stacker => ../stacker
replace kiki/tooter => ../tooter
replace kiki/file_watcher => ../file_watcher/
require (
github.com/mattn/go-mastodon v0.0.9
github.com/urfave/cli/v3 v3.2.0
kiki/config v0.0.0-00010101000000-000000000000
kiki/file_watcher v0.0.0-00010101000000-000000000000
kiki/stacker v0.0.0-00010101000000-000000000000
kiki/tooter v0.0.0-00010101000000-000000000000
)
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/gofeed v1.3.0 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

113
service/kiki.go Normal file
View file

@ -0,0 +1,113 @@
package main
import (
"context"
"log"
"net/url"
"os"
"path/filepath"
"slices"
"time"
"kiki/config"
"kiki/file_watcher"
"kiki/stacker"
"kiki/tooter"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli/v3"
)
func main() {
cmd := &cli.Command{
Name: "kiki",
Usage: "Ретранслятор из RSS в Mastodon. Когда-нибудь...",
Commands: []*cli.Command{
{
Name: "init",
Usage: "Инициализировать клиента и создать secret.conf, в котором будут храниться данные для доступа к учетной записи",
Action: func(ctx context.Context, cmd *cli.Command) error {
confFile, err := filepath.Abs("config.yaml")
kikiConfig := config.GetKikiConfig(confFile)
if err != nil {
log.Println(err)
}
instanceUrlParser, err := url.Parse(kikiConfig.Instance)
if err != nil {
log.Println(err)
}
instanceUrlParser.Scheme = "https"
tooter.ClientConfiguration(instanceUrlParser.String())
return nil
},
},
{
Name: "run",
Usage: "Запуск транслятора",
Action: func(ctx context.Context, cmd *cli.Command) error {
mastoClient := mastodon.NewClient(config.GetSecrets("secret.conf"))
confFile, err := filepath.Abs("config.yaml")
if err != nil {
log.Println(err)
}
kikiConfig := config.GetKikiConfig(confFile)
lastFileMod, err := file_watcher.GetFileModTime(confFile)
log.Println(lastFileMod)
if err != nil {
log.Println(err)
}
rdb := stacker.ConnectToRedis(kikiConfig.Redis.Address)
defer stacker.SaveRedis(rdb)
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if file_watcher.IsFileChange(&lastFileMod, confFile) {
log.Println(lastFileMod)
log.Println("RSS ленты перечитаны")
kikiConfig.RSSURLs = config.GetKikiConfig(confFile).RSSURLs
}
for _, rssUrl := range kikiConfig.RSSURLs {
newPost := tooter.NewsText(rssUrl.Url)
for _, post := range slices.Backward(newPost) {
inStack, err := stacker.CheckInRedis(rdb, post.GUID)
if err != nil {
log.Println(err)
}
if !inStack {
log.Println(post.Description)
toot, err := tooter.CreateToot(*mastoClient, post, rssUrl.Sensitive, rssUrl.Visibility)
if err != nil {
log.Println(err)
}
tooter.CreatePost(*mastoClient, toot)
stacker.SetToRedis(rdb, post.GUID, post.Description)
}
}
}
}
return nil
},
},
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}

10
stacker/go.mod Normal file
View file

@ -0,0 +1,10 @@
module kiki/stacker
go 1.24.2
require github.com/redis/go-redis/v9 v9.7.3
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
)

47
stacker/stacker.go Normal file
View file

@ -0,0 +1,47 @@
package stacker
import (
"context"
"github.com/redis/go-redis/v9"
)
// Создание подключения к Redis
func ConnectToRedis(addr string) redis.Client {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: "",
DB: 0,
})
return *rdb
}
// Создание записи в Redis о том, что этот пост отправлен
func SetToRedis(rdb redis.Client, key string, val interface{}) error {
err := rdb.Set(context.Background(), key, val, 0).Err()
if err != nil {
return err
}
return nil
}
// Проверка есть ли пост в Redis или нет
func CheckInRedis(rdb redis.Client, key string) (bool, error) {
_, err := rdb.Get(context.Background(), key).Result()
if err == redis.Nil {
return false, nil
} else if err != nil {
return false, err
}
return true, nil
}
// Сохранение базы данных Redis
func SaveRedis(rdb redis.Client) error {
err := rdb.Save(context.Background()).Err()
if err != nil {
return err
}
return nil
}

View file

@ -1,27 +1,25 @@
module kiki module kiki/tooter
go 1.23.0 go 1.24.2
toolchain go1.24.2
require ( require (
github.com/go-yaml/yaml v2.1.0+incompatible
github.com/mattn/go-mastodon v0.0.9 github.com/mattn/go-mastodon v0.0.9
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
github.com/urfave/cli/v3 v3.1.1
golang.org/x/net v0.39.0 golang.org/x/net v0.39.0
gopkg.in/yaml.v2 v2.4.0
kiki/config v0.0.0-00010101000000-000000000000
) )
require ( require (
github.com/PuerkitoBio/goquery v1.10.2 // indirect github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.24.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )
replace kiki/config => ../config

205
tooter/tooter.go Normal file
View file

@ -0,0 +1,205 @@
package tooter
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"kiki/config"
"github.com/mattn/go-mastodon"
"github.com/mmcdole/gofeed"
"golang.org/x/net/html"
"gopkg.in/yaml.v2"
)
// Функция создает файл secret.conf, в котором хранятся данные для доступа к аккаунту
func ClientConfiguration(Instance string) {
appConfig := &mastodon.AppConfig{
Server: Instance,
ClientName: "Kiki",
Scopes: "read write follow",
Website: "catgirls.asia",
RedirectURIs: "urn:ietf:wg:oauth:2.0:oob",
}
app, err := mastodon.RegisterApp(context.Background(), appConfig)
if err != nil {
log.Println(err)
}
u, err := url.Parse(app.AuthURI)
if err != nil {
log.Println(err)
}
var userToken string
fmt.Printf("Перейдите по ссылке\n%s\nИ введите user token ниже:\n", u)
fmt.Scanln(&userToken)
conf := &mastodon.Config{
Server: Instance,
ClientID: app.ClientID,
ClientSecret: app.ClientSecret,
AccessToken: userToken,
}
mastoClient := mastodon.NewClient(conf)
err = mastoClient.AuthenticateToken(context.Background(), userToken, "urn:ietf:wg:oauth:2.0:oob")
if err != nil {
log.Println(err)
}
clientData := config.MastodonClientData{
Instance: Instance,
ClientID: mastoClient.Config.ClientID,
ClientSecret: mastoClient.Config.ClientSecret,
AccessToken: mastoClient.Config.AccessToken,
}
marshaledYaml, err := yaml.Marshal(clientData)
if err != nil {
log.Println(err)
}
log.Println(string(marshaledYaml))
secretConfig, err := os.OpenFile("secret.conf", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
log.Println(err)
}
secretConfig.Write(marshaledYaml)
defer secretConfig.Close()
}
// Возвращает срез новостей, полученных из RSS ленты
func NewsText(url string) []*gofeed.Item {
fp := gofeed.NewParser()
feed, err := fp.ParseURL(url)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
log.Println("Timeout Error:", err)
return []*gofeed.Item{}
} else {
log.Println(err)
return []*gofeed.Item{}
}
}
log.Println("RSS лента получена")
return feed.Items
}
// Отправляет созданный пост
func CreatePost(mastoClient mastodon.Client, toot mastodon.Toot) {
_, err := mastoClient.PostStatus(context.Background(), &toot)
if err != nil {
log.Println(err)
}
}
// Обходит срез ссылок на изображения и возвращает срез, состоящий из представления картинок в виде срезов байтов
func PicBytesArray(picturesArray []string) [][]byte {
var picturesBytes [][]byte
for _, picture := range picturesArray {
resp, err := http.Get(picture)
if err != nil {
log.Println(err)
return picturesBytes
}
defer resp.Body.Close()
picBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Println(err)
return picturesBytes
}
picturesBytes = append(picturesBytes, picBytes)
}
return picturesBytes
}
// Загружает на инстанс изображения, после чего возвращает массив прикрепленных медиа в Mastodon. Необходимо для получения MediaID для каждого изображения
func UploadPictures(mastoClient mastodon.Client, filesBytes [][]byte) []*mastodon.Attachment {
var attachments []*mastodon.Attachment
for _, file := range filesBytes {
att, err := mastoClient.UploadMediaFromBytes(context.Background(), file)
if err != nil {
log.Println(err)
return attachments
}
attachments = append(attachments, att)
}
return attachments
}
// Формирование тела статуса
func CreateToot(mastoClient mastodon.Client, newsDesc *gofeed.Item, sensitive bool, visibility string) (mastodon.Toot, error) {
var imgArray []string
var attachments []*mastodon.Attachment
var tootText string = fmt.Sprintf("src: %s\n\n", newsDesc.Link)
toot := mastodon.Toot{
Visibility: visibility,
Sensitive: sensitive,
}
uString := html.UnescapeString(newsDesc.Description)
pHtml, err := html.Parse(strings.NewReader(uString))
if err != nil {
return mastodon.Toot{}, err
}
for n := range pHtml.Descendants() {
if n.Type != html.ElementNode {
tootText += (n.Data)
}
if n.Type == html.ElementNode {
switch n.Data {
case "img":
for _, attr := range n.Attr {
if attr.Key == "src" {
imgArray = append(imgArray, attr.Val)
}
}
case "br", "p":
tootText += "\n"
}
}
}
if len(imgArray) != 0 {
attachments = UploadPictures(mastoClient, PicBytesArray(imgArray))
}
toot.Status = tootText
for _, attach := range attachments {
toot.MediaIDs = append(toot.MediaIDs, attach.ID)
}
return toot, nil
}