Compare commits

..

No commits in common. "master" and "add_redis" have entirely different histories.

14 changed files with 29 additions and 256 deletions

1
.gitignore vendored
View file

@ -19,7 +19,6 @@ 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 kiki
redis_data
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/

View file

@ -1,27 +1,16 @@
FROM golang:1.24-alpine AS build FROM golang:1.24
RUN apk add --no-cache make
WORKDIR /app/kiki WORKDIR /app/kiki
RUN mkdir /app/kiki/config /app/kiki/service /app/kiki/stacker /app/kiki/tooter /app/kiki/file_watcher RUN mkdir /app/kiki/config /app/kiki/service /app/kiki/stacker /app/kiki/tooter
COPY config/* /app/kiki/config COPY config/* /app/kiki/config
COPY service/* /app/kiki/service COPY service/* /app/kiki/service
COPY stacker/* /app/kiki/stacker COPY stacker/* /app/kiki/stacker
COPY tooter/* /app/kiki/tooter COPY tooter/* /app/kiki/tooter
COPY file_watcher/* /app/kiki/file_watcher
COPY Makefile /app/kiki/ RUN cd service && go mod tidy && cd ../
RUN make RUN go build -C ./service -o ../kiki
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" ] CMD [ "/app/kiki/kiki", "run" ]

View file

@ -1,13 +0,0 @@
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.

View file

@ -1,15 +0,0 @@
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

View file

@ -1,88 +0,0 @@
# 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,10 +1,5 @@
instance: https://pleroma.catgirls.asia instance: https://pleroma.catgirls.asia
rss_urls: rss_url: https://4pda.to/feed
- url: https://habr.com/ru/rss/flows/admin/articles/?fl=ru sensitive: true
sensitive: false
visibility: "unlisted"
- url: https://4pda.to/feed
sensitive: false
visibility: "unlisted"
redis: redis:
address: localhost:6379 address: localhost:6379

View file

@ -8,20 +8,15 @@ import (
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
// Сруктура config.yaml
type KikiSettings struct { type KikiSettings struct {
Instance string `yaml:"instance,omitempty"` Instance string `yaml:"instance,omitempty"`
RSSURLs []struct { RSSUri string `yaml:"rss_url,omitempty"`
Url string `yaml:"url,omitempty"` Sensitive bool `yaml:"sensitive,omitempty"`
Sensitive bool `yaml:"sensitive,omitempty"` Redis struct {
Visibility string `yaml:"visibility"`
} `yaml:"rss_urls,omitempty"`
Redis struct {
Address string `yaml:"address"` Address string `yaml:"address"`
} `yaml:"redis"` } `yaml:"redis"`
} }
// Структура secret.conf
type MastodonClientData struct { type MastodonClientData struct {
ClientID string `yaml:"clientID,omitempty"` ClientID string `yaml:"clientID,omitempty"`
ClientSecret string `yaml:"clientSecret,omitempty"` ClientSecret string `yaml:"clientSecret,omitempty"`
@ -29,7 +24,6 @@ type MastodonClientData struct {
Instance string `yaml:"instance,omitempty"` Instance string `yaml:"instance,omitempty"`
} }
// Получение данных из конфига config.yaml
func GetKikiConfig(path string) KikiSettings { func GetKikiConfig(path string) KikiSettings {
var kikiSettings KikiSettings var kikiSettings KikiSettings
@ -46,7 +40,6 @@ func GetKikiConfig(path string) KikiSettings {
return kikiSettings return kikiSettings
} }
// Получение данных из конфига secret.conf
func GetSecrets(path string) *mastodon.Config { func GetSecrets(path string) *mastodon.Config {
var clientData MastodonClientData var clientData MastodonClientData

View file

@ -1,6 +1,6 @@
services: services:
kiki: kiki:
image: git.catgirls.asia/b4d_us3r/kiki:latest build: ./
restart: always restart: always
volumes: volumes:
- ./config.yaml:/app/kiki/config.yaml - ./config.yaml:/app/kiki/config.yaml
@ -10,5 +10,4 @@ services:
redis: redis:
image: redis image: redis
restart: always restart: always
volumes:
- ./redis_data:/data

View file

@ -1,32 +0,0 @@
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
}

View file

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

View file

@ -8,13 +8,10 @@ replace kiki/stacker => ../stacker
replace kiki/tooter => ../tooter replace kiki/tooter => ../tooter
replace kiki/file_watcher => ../file_watcher/
require ( require (
github.com/mattn/go-mastodon v0.0.9 github.com/mattn/go-mastodon v0.0.9
github.com/urfave/cli/v3 v3.2.0 github.com/urfave/cli/v3 v3.2.0
kiki/config v0.0.0-00010101000000-000000000000 kiki/config v0.0.0-00010101000000-000000000000
kiki/file_watcher v0.0.0-00010101000000-000000000000
kiki/stacker v0.0.0-00010101000000-000000000000 kiki/stacker v0.0.0-00010101000000-000000000000
kiki/tooter v0.0.0-00010101000000-000000000000 kiki/tooter v0.0.0-00010101000000-000000000000
) )

View file

@ -6,11 +6,9 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"time" "time"
"kiki/config" "kiki/config"
"kiki/file_watcher"
"kiki/stacker" "kiki/stacker"
"kiki/tooter" "kiki/tooter"
@ -25,7 +23,7 @@ func main() {
Commands: []*cli.Command{ Commands: []*cli.Command{
{ {
Name: "init", Name: "init",
Usage: "Инициализировать клиента и создать secret.conf, в котором будут храниться данные для доступа к учетной записи", Usage: "Инициализировать клиента",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
confFile, err := filepath.Abs("config.yaml") confFile, err := filepath.Abs("config.yaml")
kikiConfig := config.GetKikiConfig(confFile) kikiConfig := config.GetKikiConfig(confFile)
@ -57,48 +55,32 @@ func main() {
} }
kikiConfig := config.GetKikiConfig(confFile) 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) rdb := stacker.ConnectToRedis(kikiConfig.Redis.Address)
defer stacker.SaveRedis(rdb) defer stacker.SaveRedis(rdb)
ticker := time.NewTicker(1 * time.Minute) ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
if file_watcher.IsFileChange(&lastFileMod, confFile) { newPosts := tooter.NewsText(kikiConfig.RSSUri)
log.Println(lastFileMod)
log.Println("RSS ленты перечитаны")
kikiConfig.RSSURLs = config.GetKikiConfig(confFile).RSSURLs
}
for _, rssUrl := range kikiConfig.RSSURLs { for _, post := range newPosts {
newPost := tooter.NewsText(rssUrl.Url) inStack, err := stacker.CheckInRedis(rdb, post.GUID)
if err != nil {
log.Println(err)
}
for _, post := range slices.Backward(newPost) { if !inStack {
inStack, err := stacker.CheckInRedis(rdb, post.GUID) log.Println(post.Description)
toot, err := tooter.CreateToot(*mastoClient, post, kikiConfig.Sensitive)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
} }
if !inStack { tooter.CreatePost(*mastoClient, toot)
log.Println(post.Description)
toot, err := tooter.CreateToot(*mastoClient, post, rssUrl.Sensitive, rssUrl.Visibility) stacker.SetToRedis(rdb, post.GUID, post.Description)
if err != nil {
log.Println(err)
}
tooter.CreatePost(*mastoClient, toot)
stacker.SetToRedis(rdb, post.GUID, post.Description)
}
} }
} }
} }

View file

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

View file

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -19,7 +18,6 @@ import (
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
// Функция создает файл secret.conf, в котором хранятся данные для доступа к аккаунту
func ClientConfiguration(Instance string) { func ClientConfiguration(Instance string) {
appConfig := &mastodon.AppConfig{ appConfig := &mastodon.AppConfig{
Server: Instance, Server: Instance,
@ -39,7 +37,7 @@ func ClientConfiguration(Instance string) {
log.Println(err) log.Println(err)
} }
var userToken string var userToken string
//fmt.Println(u)
fmt.Printf("Перейдите по ссылке\n%s\nИ введите user token ниже:\n", u) fmt.Printf("Перейдите по ссылке\n%s\nИ введите user token ниже:\n", u)
fmt.Scanln(&userToken) fmt.Scanln(&userToken)
@ -69,38 +67,24 @@ func ClientConfiguration(Instance string) {
} }
log.Println(string(marshaledYaml)) log.Println(string(marshaledYaml))
secretConfig, err := os.OpenFile("secret.conf", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) secretConfig, err := os.OpenFile("secret.conf", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
} }
secretConfig.Write(marshaledYaml) secretConfig.Write(marshaledYaml)
defer secretConfig.Close() defer secretConfig.Close()
} }
// Возвращает срез новостей, полученных из RSS ленты
func NewsText(url string) []*gofeed.Item { func NewsText(url string) []*gofeed.Item {
fp := gofeed.NewParser() fp := gofeed.NewParser()
feed, err := fp.ParseURL(url) feed, err := fp.ParseURL(url)
if err != nil { if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() { log.Println(err)
log.Println("Timeout Error:", err)
return []*gofeed.Item{}
} else {
log.Println(err)
return []*gofeed.Item{}
}
} }
log.Println("RSS лента получена") log.Println("RSS лента получена")
return feed.Items return feed.Items
} }
// Отправляет созданный пост
func CreatePost(mastoClient mastodon.Client, toot mastodon.Toot) { func CreatePost(mastoClient mastodon.Client, toot mastodon.Toot) {
_, err := mastoClient.PostStatus(context.Background(), &toot) _, err := mastoClient.PostStatus(context.Background(), &toot)
if err != nil { if err != nil {
@ -108,34 +92,25 @@ func CreatePost(mastoClient mastodon.Client, toot mastodon.Toot) {
} }
} }
// Обходит срез ссылок на изображения и возвращает срез, состоящий из представления картинок в виде срезов байтов
func PicBytesArray(picturesArray []string) [][]byte { func PicBytesArray(picturesArray []string) [][]byte {
var picturesBytes [][]byte var picturesBytes [][]byte
for _, picture := range picturesArray { for _, picture := range picturesArray {
resp, err := http.Get(picture) resp, err := http.Get(picture)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
return picturesBytes return picturesBytes
} }
defer resp.Body.Close() defer resp.Body.Close()
picBytes, err := io.ReadAll(resp.Body) picBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
return picturesBytes return picturesBytes
} }
picturesBytes = append(picturesBytes, picBytes) picturesBytes = append(picturesBytes, picBytes)
} }
return picturesBytes return picturesBytes
} }
// Загружает на инстанс изображения, после чего возвращает массив прикрепленных медиа в Mastodon. Необходимо для получения MediaID для каждого изображения
func UploadPictures(mastoClient mastodon.Client, filesBytes [][]byte) []*mastodon.Attachment { func UploadPictures(mastoClient mastodon.Client, filesBytes [][]byte) []*mastodon.Attachment {
var attachments []*mastodon.Attachment var attachments []*mastodon.Attachment
@ -153,15 +128,14 @@ func UploadPictures(mastoClient mastodon.Client, filesBytes [][]byte) []*mastodo
return attachments return attachments
} }
// Формирование тела статуса func CreateToot(mastoClient mastodon.Client, newsDesc *gofeed.Item, sensitive bool) (mastodon.Toot, error) {
func CreateToot(mastoClient mastodon.Client, newsDesc *gofeed.Item, sensitive bool, visibility string) (mastodon.Toot, error) {
var imgArray []string var imgArray []string
var attachments []*mastodon.Attachment var attachments []*mastodon.Attachment
var tootText string = fmt.Sprintf("src: %s\n\n", newsDesc.Link) var tootText string = fmt.Sprintf("src: %s\n\n", newsDesc.Link)
toot := mastodon.Toot{ toot := mastodon.Toot{
Visibility: visibility, Visibility: "unlisted",
Sensitive: sensitive, Sensitive: sensitive,
} }