diff --git a/twitch-hooks/LICENSE b/twitch-hooks/LICENSE new file mode 100644 index 0000000..1b44f7d --- /dev/null +++ b/twitch-hooks/LICENSE @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2021 Unbewohne | Nikolay Kasyanov + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/twitch-hooks/README.txt b/twitch-hooks/README.txt new file mode 100644 index 0000000..03a7c76 --- /dev/null +++ b/twitch-hooks/README.txt @@ -0,0 +1,111 @@ + _______ _ _ _ _ _ + |__ __| (_) | | | | | | | + | |_ ___| |_ ___| |__ ______| |__ ___ ___ | | _____ + | \ \ /\ / / | __/ __| '_ \______| '_ \ / _ \ / _ \| |/ / __| + | |\ V V /| | || (__| | | | | | | | (_) | (_) | <\__ \ + |_| \_/\_/ |_|\__\___|_| |_| |_| |_|\___/ \___/|_|\_\___/ +by Unbewohnte + +[Eng] +! Purpose +This program`s purpose is to send custom messages +to Vk`s users/group chats and discord`s text channels +via webhooks when the Twitch stream is online. + +! First run +When you run it the first time - it`ll generate a config.cfg +in your working directory and exit. + +! Config file +Config file`s contents are present in JSON format. + +Field "TwitchName" must contain Twitch channel`s name. The +CLI will be checking if this user has started streaming or not. + +In block "Keys" you should paste at least all Twitch keys and +at least one of the fields that are left (Discord`s webhook url or Vk`s api key) + +If "force-send" == true - the program won`t check for any streams and just +send messages you`ve stated it to send. + +In block "Messages" you can specify what kind of messages the CLI will +send in case of the stream. Usually it`s a little opening followed by a +stream`s link. + +!! "receiver_id" and "is_group_chat" +If you want to send a message to the group chat in VK and +set "is_group_chat" to true, then "receiver_id" must be your +personal id of that group chat. If you navigate to it in your +browser - you`ll see such pattern in the end of the URL: c20. In this case +20 is my personal id for some of my group chats. You should +set yours to "receiver_id". + +! Next runs +After you`ve finished your configuration - the program`s ready to +work. Just execute it again and it`ll check every 5 minutes for specified +user`s stream. If it`s started - it`ll send your custom messages and exit. + + +! Api keys + +!! Twitch +https://dev.twitch.tv/console - here, create an app and grab all the necessary keys + +!! Discord +Create a webhook in the server`s settings and copy its URL + +!! Vk +Create your own app, or proceed here: https://vkhost.github.io/ and grab your API key without a headache + + + +[Ru] +! Назначение +Эта программа предназначена для отсылания оповещений о начавшемся +Twitch стриме на указанном канале пользователям/группе вКонтакте и +в текстовый канал Discord через webhook + +! Первый запуск +При первом запуске cli генерирует файл в рабочей директории с именем +"config.cfg". + +! Конфигурационный файл +Файл структурирован в виде JSON формата. + +В поле "TwitchName" следует указать имя канала +на Твиче. Программа будет отслеживать начало стрима данного +пользователя. + +В блоке "Keys" следует вставить как минимум все ключи от Twitch и +хотя-бы один из оставшихся url или ключей от ВК или Дискорда. + +Если "force-send" == true - программа не станет ждать начала стрима +и сразу отошлёт указанные вами сообщения + +В блоке "Messages" следует указать сообщение, которые вы хотите +отослать в случае начала стрима. Обычно это небольшое вступление +и ссылка на стрим. + +!! "receiver_id" и "is_group_chat" +Если вы хотите отослать сообщение в групповой чат ВК и +выставили is_group_chat на true, то receiver_id будет ваше +личное ид данного чата. Чтобы его получить, зайдите в него и +в конце URL вы заметите что-то наподобие такого: с20. 20 в данном +случае и есть ид группового чата. + +! Последующие запуски +После настройки конфигурационного файла программа готова к +полному использованию. Просто снова запустите её и она каждые +5 минут будет проверять наличие стрима на указанном канале. +Если стрим идёт - она отправит сообщения и закроется. + +! Api ключи + +!! Twitch +https://dev.twitch.tv/console - создай своё приложение и забери все необходимые ключи + +!! Discord +Создай вебхук в настройках сервера и скопируй ссылку + +!! Vk +Создай своё приложение, или просто зайди сюда: https://vkhost.github.io/ и забери свой ключ без головной боли diff --git a/twitch-hooks/config/config.go b/twitch-hooks/config/config.go new file mode 100644 index 0000000..9b002ac --- /dev/null +++ b/twitch-hooks/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "twitch-hooks/discordhooks" + "twitch-hooks/twitchhooks" + "twitch-hooks/vkhooks" +) + +const configFilename string = "config.cfg" + +type keys struct { + Twitch twitchhooks.Keys + Discord discordhooks.Webhook + VK vkhooks.ApiKey +} + +type messages struct { + DiscordMessage discordhooks.Message + VKmessage vkhooks.Message +} + +type Config struct { + TwitchName string + Keys keys + ForceSend bool `json:"force-send"` + Messages messages +} + +// Checks if config file exists in the same directory +func ConfigExists() bool { + _, err := os.Stat(configFilename) + if err != nil { + return false + } + return true +} + +// Creates a new config file in current directory. +func CreateConfig() error { + // create a config file in the same directory + configF, err := os.Create(configFilename) + if err != nil { + return fmt.Errorf("could not create a config file: %s", err) + } + + // write default config fields + defaults, err := json.MarshalIndent(&Config{}, "", " ") + if err != nil { + return fmt.Errorf("could not marshal default config fields: %s", err) + } + _, err = configF.Write(defaults) + if err != nil { + return fmt.Errorf("could not write defaults to config: %s", err) + } + + return nil +} + +// Opens and reads config file, returns `Config` struct. +// If ReadConfig cannot unmarshal config file - it creates a new one with +// all default fields +func ReadConfig() (*Config, error) { + // get config`s contents + configContents, err := os.ReadFile(configFilename) + if err != nil { + return nil, fmt.Errorf("could not read config: %s", err) + } + + var config Config + err = json.Unmarshal(configContents, &config) + if err != nil { + _ = CreateConfig() + return nil, fmt.Errorf("could not unmarshal config: %s\nCreatead a new one", err) + } + + // remove uneccessary spaces + config.Keys.Discord.WebhookUrl = strings.TrimSpace(config.Keys.Discord.WebhookUrl) + config.Keys.Twitch.ClientID = strings.TrimSpace(config.Keys.Twitch.ClientID) + config.Keys.Twitch.ClientSecret = strings.TrimSpace(config.Keys.Twitch.ClientSecret) + config.Keys.VK.Key = strings.TrimSpace(config.Keys.VK.Key) + + // validate inputs + if config.Keys.Discord.WebhookUrl == "" && + config.Keys.Twitch.ClientID == "" && + config.Keys.Twitch.ClientSecret == "" && + config.Keys.VK.Key == "" { + + return nil, fmt.Errorf("does not use any keys") + } + if len(config.TwitchName) < 2 { + return nil, fmt.Errorf("twitch name is too short") + } + + return &config, nil +} diff --git a/twitch-hooks/discordhooks/discordhook.go b/twitch-hooks/discordhooks/discordhook.go new file mode 100644 index 0000000..4e63d55 --- /dev/null +++ b/twitch-hooks/discordhooks/discordhook.go @@ -0,0 +1,36 @@ +package discordhooks + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type Webhook struct { + WebhookUrl string +} + +const contentTypeJson string = "application/json" + +type Message struct { + Message string `json:"content"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` +} + +// Post Message struct to given webhook +func Post(webhookUrl string, message Message) error { + json, err := json.Marshal(&message) + if err != nil { + return fmt.Errorf("could not marshal given JsonMessage: %s", err) + } + + resp, err := http.Post(webhookUrl, contentTypeJson, bytes.NewBuffer(json)) + if err != nil { + return fmt.Errorf("could not POST to given url: %s", err) + } + defer resp.Body.Close() + + return nil +} diff --git a/twitch-hooks/go.mod b/twitch-hooks/go.mod new file mode 100644 index 0000000..e4edd85 --- /dev/null +++ b/twitch-hooks/go.mod @@ -0,0 +1,5 @@ +module twitch-hooks + +go 1.16 + +require github.com/go-vk-api/vk v0.0.0-20200129183856-014d9b8adc96 diff --git a/twitch-hooks/go.sum b/twitch-hooks/go.sum new file mode 100644 index 0000000..77ff783 --- /dev/null +++ b/twitch-hooks/go.sum @@ -0,0 +1,4 @@ +github.com/go-vk-api/vk v0.0.0-20200129183856-014d9b8adc96 h1:gFOrXsbjC+XvHIVMx608V/mRVZwmU9xofR1Vht9p3Zg= +github.com/go-vk-api/vk v0.0.0-20200129183856-014d9b8adc96/go.mod h1:UeBKPsuqp+KSBDtC7gr+Lng+TEaoFXT/mpOs6Tj7tFQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/twitch-hooks/main.go b/twitch-hooks/main.go new file mode 100644 index 0000000..47ca540 --- /dev/null +++ b/twitch-hooks/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "os" + "time" + "twitch-hooks/config" + "twitch-hooks/discordhooks" + "twitch-hooks/twitchhooks" + "twitch-hooks/vkhooks" +) + +var logo string = ` _______ _ _ _ _ _ +|__ __| (_) | | | | | | | + | |_ ___| |_ ___| |__ ______| |__ ___ ___ | | _____ + | \ \ /\ / / | __/ __| '_ \______| '_ \ / _ \ / _ \| |/ / __| + | |\ V V /| | || (__| | | | | | | | (_) | (_) | <\__ \ + |_| \_/\_/ |_|\__\___|_| |_| |_| |_|\___/ \___/|_|\_\___/ by Unbewohnte` +var Config config.Config + +func init() { + // process the config file + + if !config.ConfigExists() { + // there is no existing config file; + // create a new one and exit + err := config.CreateConfig() + if err != nil { + panic(err) + } + + fmt.Println("Created a new config file") + os.Exit(0) + } + + configContents, err := config.ReadConfig() + if err != nil { + panic(err) + } + + Config = *configContents +} + +func main() { + fmt.Println(logo) + + if Config.Keys.Twitch.ClientID == "" || Config.Keys.Twitch.ClientSecret == "" { + // no twitch api key used. Notify the user and check for the force-send flag + fmt.Println("No Twitch API keys found") + + if !Config.ForceSend { + // not forced to send messages. Exiting + fmt.Println("Not forced to send. Exiting...") + os.Exit(0) + } + } + + var delay = time.Second * 300 + fmt.Printf("Delay: %s\n", delay) + // mainloop + for { + // retrieve access token + tokenResp, err := twitchhooks.GetToken(&Config.Keys.Twitch) + if err != nil { + panic(err) + } + + // check if live + is_live, err := twitchhooks.IsLive(Config.TwitchName, &twitchhooks.RequestOptions{ + ApplicationKeys: Config.Keys.Twitch, + AccessToken: *tokenResp, + }) + if err != nil { + panic(err) + } + + if is_live || Config.ForceSend { + // live or forced to send -> send alerts + fmt.Println("Live !") + + if Config.Keys.Discord.WebhookUrl != "" { + err := discordhooks.Post(Config.Keys.Discord.WebhookUrl, Config.Messages.DiscordMessage) + if err != nil { + panic(err) + } + } + + if Config.Keys.VK.Key != "" { + vkhooks.Initialise(Config.Keys.VK.Key) + err := vkhooks.Send(Config.Messages.VKmessage) + if err != nil { + panic(err) + } + } + + // alerted. Now exiting + fmt.Println("Alerts has been sent ! My work is done here...") + os.Exit(0) + } + + // sleeping + time.Sleep(delay) + } +} diff --git a/twitch-hooks/twitchhooks/twitchhook.go b/twitch-hooks/twitchhooks/twitchhook.go new file mode 100644 index 0000000..6f59860 --- /dev/null +++ b/twitch-hooks/twitchhooks/twitchhook.go @@ -0,0 +1,116 @@ +package twitchhooks + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type Keys struct { + ClientID string + ClientSecret string +} + +// access token response struct +type TokenResponse struct { + AcessToken string `json:"access_token"` + ExpiresIn uint `json:"expires_in"` + TokenType string `json:"token_type"` +} + +type RequestOptions struct { + ApplicationKeys Keys + AccessToken TokenResponse +} + +// Retrieves access token from Twitch +func GetToken(keys *Keys) (*TokenResponse, error) { + getTokenUrl := fmt.Sprintf("https://id.twitch.tv/oauth2/token?client_id=%s&client_secret=%s&grant_type=client_credentials", + keys.ClientID, keys.ClientSecret) + + resp, err := http.Post(getTokenUrl, "", bytes.NewBuffer([]byte{})) + if err != nil { + return nil, fmt.Errorf("could not make a post request: %s", err) + } + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var tokenResp TokenResponse + err = json.Unmarshal(content, &tokenResp) + if err != nil { + return nil, fmt.Errorf("could not unmarshal token response: %s", err) + } + + return &tokenResp, nil +} + +// gets data about user from api endpoint +func GetUser(displayname string, options *RequestOptions) (string, error) { + requestUrl := fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", displayname) + + httpClient := http.Client{} + + request, err := http.NewRequest("GET", requestUrl, new(bytes.Buffer)) + if err != nil { + return "", fmt.Errorf("could not create a new twitch request: %s", err) + } + defer request.Body.Close() + + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", options.AccessToken.AcessToken)) + request.Header.Add("Client-id", options.ApplicationKeys.ClientID) + + response, err := httpClient.Do(request) + if err != nil { + return "", fmt.Errorf("could not make a request to twitch api: %s", err) + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + return string(data), nil +} + +// Checks if the user streaming right now +func IsLive(displayname string, options *RequestOptions) (bool, error) { + requestUrl := fmt.Sprintf("https://api.twitch.tv/helix/streams?user_login=%s", displayname) + + httpClient := http.Client{} + + request, err := http.NewRequest("GET", requestUrl, new(bytes.Buffer)) + if err != nil { + return false, fmt.Errorf("could not create a new twitch request: %s", err) + } + defer request.Body.Close() + + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", options.AccessToken.AcessToken)) + request.Header.Add("Client-id", options.ApplicationKeys.ClientID) + + response, err := httpClient.Do(request) + if err != nil { + return false, fmt.Errorf("could not make a request to twitch api: %s", err) + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return false, err + } + + // check if got an empty response -> offline + if len(data) <= 28 { + return false, nil + } + + return true, nil +} + +func GetStream() { + +} diff --git a/twitch-hooks/vkhooks/vkhook.go b/twitch-hooks/vkhooks/vkhook.go new file mode 100644 index 0000000..f24a8cf --- /dev/null +++ b/twitch-hooks/vkhooks/vkhook.go @@ -0,0 +1,58 @@ +package vkhooks + +import ( + "fmt" + + vk "github.com/go-vk-api/vk" +) + +type ApiKey struct { + Key string +} + +var client *vk.Client + +type Message struct { + Message string `json:"message"` + GroupChat bool `json:"is_group_chat"` + ID uint `json:"receiver_id"` +} + +func Initialise(vkapikey string) { + // create a client on init + vkClient, err := vk.NewClientWithOptions( + vk.WithToken(vkapikey), + ) + if err != nil { + panic(err) + } + + client = vkClient +} + +// Sends message to the given id +func Send(message Message) error { + switch message.GroupChat { + case true: + err := client.CallMethod("messages.send", vk.RequestParams{ + "chat_id": message.ID, + "message": message.Message, + "random_id": 0, + }, nil) + if err != nil { + return fmt.Errorf("could not send vk message: %s", err) + } + + case false: + err := client.CallMethod("messages.send", vk.RequestParams{ + "peer_id": message.ID, + "message": message.Message, + "random_id": 0, + }, nil) + if err != nil { + return fmt.Errorf("could not send vk message: %s", err) + } + } + + return nil +}