remove everything except the frontend

This commit is contained in:
boring_nick 2023-03-11 18:26:08 +02:00
parent 020333b358
commit 61157c4cd5
75 changed files with 25 additions and 3555 deletions

View File

@ -1,44 +0,0 @@
name: ci
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get -v -t -d ./...
- name: Build
run: go build -v .
- name: Test
run: go test -v .
release:
if: ${{ github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Set outputs
id: vars
run: echo "::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-4)"

View File

@ -1,77 +0,0 @@
name: Docker
on:
push:
# Publish `main` as Docker `latest` image.
branches:
- main
# Publish `v1.2.3` tags as releases.
tags:
- v*
# Run tests for any PRs.
pull_request:
env:
# TODO: Change variable to your image's name.
IMAGE_NAME: justlog
jobs:
# Run tests.
# See also https://docs.docker.com/docker-hub/builds/automated-testing/
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run tests
run: |
if [ -f docker-compose.test.yml ]; then
docker-compose --file docker-compose.test.yml build
docker-compose --file docker-compose.test.yml run sut
else
docker build . --file Dockerfile
fi
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
push:
# Ensure test job passes before pushing image.
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v2
- name: Build image
run: docker build . --file Dockerfile --tag $IMAGE_NAME
- name: Log into GitHub Container Registry
# TODO: Create a PAT with `read:packages` and `write:packages` scopes and save it as an Actions secret `CR_PAT`
run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push image to GitHub Container Registry
run: |
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
# Change all uppercase to lowercase
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Use Docker `latest` tag convention
[ "$VERSION" == "main" ] && VERSION=latest
echo IMAGE_ID=$IMAGE_ID
echo VERSION=$VERSION
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
docker push $IMAGE_ID:$VERSION

34
.gitignore vendored
View File

@ -1,9 +1,25 @@
justlog
vendor/
.idea/
.vscode/
coverage-all.out
coverage.out
.env
logs/
config.json
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
dist/*
!dist/.gitkeep
public/swagger.json
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -1,24 +0,0 @@
FROM quay.io/goswagger/swagger:latest as build-docs
WORKDIR /app
COPY . .
RUN make docs
FROM node:18-alpine as build-web
WORKDIR /web
COPY web .
COPY --from=build-docs /app/web/public/swagger.json /web/public
RUN yarn install --ignore-optional
RUN yarn build
FROM golang:alpine as build-app
WORKDIR /app
COPY . .
COPY --from=build-web /web/dist /app/web/dist
RUN go build -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=build-app /app/app .
USER 1000:1000
CMD ["./app", "--config=/etc/justlog.json"]
EXPOSE 8025

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2018 gempir
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.

View File

@ -1,26 +0,0 @@
full: docs web build
build:
go build
run: build
./justlog
run_web:
cd web && yarn start
web: init_web
cd web && yarn build
init_web:
cd web && yarn install --ignore-optional
container:
docker build -t gempir/justlog .
run_container:
docker run -p 8025:8025 --restart=unless-stopped -v $(PWD)/config.json:/etc/justlog.json -v $(PWD)/logs:/logs gempir/justlog:latest
docs:
swagger generate spec -m -o ./web/public/swagger.json -w api

View File

@ -1,56 +0,0 @@
# justlog [![Build Status](https://github.com/gempir/justlog/workflows/ci/badge.svg)](https://github.com/gempir/justlog/actions?query=workflow%3Aci)
### What is this?
Justlog is an twitch irc bot. It focuses on logging and providing an api for the logs.
### Optout
Click the X icon on the web ui to find a explanation how to opt out.
### API
API documentation can be viewed via the justlog frontend by clicking the "docs" symbol:
![image](https://user-images.githubusercontent.com/1629196/159481078-0de98f01-2816-49bd-8e17-ba7cf66cb064.png)
### Docker
```
mkdir logs
docker run -p 8025:8025 --restart=unless-stopped -v $PWD/config.json:/etc/justlog.json -v $PWD/logs:/logs ghcr.io/gempir/justlog
```
### Commands
Only admins can use these commands
- `!justlog status` will respond with uptime
- `!justlog join gempir pajlada` will join the channels and append them to the config
- `!justlog part gempir pajlada` will part the channels and remove them from the config
- `!justlog optout gempir gempbot` will opt out users of message logging or querying previous logs of that user, same applies to users own channel
- `!justlog optin gempir gempbot` will revert the opt out
### Config
```
{
"admins": ["gempir"], // will only respond to commands executed by these users
"logsDirectory": "./logs", // the directory to log into
"adminAPIKey": "noshot", // your secret api key to access the admin api, can be any string, used in api request to admin endpoints
"username": "gempbot", // bot username (can be justinfan123123 if you don't want to use an account)
"oauth": "oauthtokenforchat", // bot token can be anything if justinfan123123
"botVerified": true, // increase ratelimits if you have a verified bot, so the bot can join faster, false by default
"clientID": "mytwitchclientid", // your client ID, needed for fetching userids or usernames etc
"clientSecret": "mysecret", // your twitch client secret
"logLevel": "info", // the log level, keep this to info probably, all options are: trace, debug, info, warn, error, fatal, and panic, logs output is stdout
"channels": ["77829817", "11148817"], // the channels (userids) you want to log
"archive": true // probably keep to true, will disable gzipping of old logs if false, useful if you setup compression on your own
}
```
### Development
Development requires [yarn](https://classic.yarnpkg.com/) and [go-swagger](https://goswagger.io/)
Run `go build && ./justlog` and `yarn start` in the web folder.
Or run `make container` and `make run_container`

View File

@ -1,121 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
)
func (s *Server) authenticateAdmin(w http.ResponseWriter, r *http.Request) bool {
apiKey := r.Header.Get("X-Api-Key")
if apiKey == "" || apiKey != s.cfg.AdminAPIKey {
http.Error(w, "No I don't think so.", http.StatusForbidden)
return false
}
return true
}
type channelsDeleteRequest struct {
// list of userIds
Channels []string `json:"channels"`
}
type channelConfigsJoinRequest struct {
// list of userIds
Channels []string `json:"channels"`
}
// swagger:route POST /admin/channels admin addChannels
//
// Will add the channels to log, only works with userIds
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
// - text/plain
//
// Security:
// - api_key:
//
// Schemes: https
//
// Responses:
// 200:
// 400:
// 405:
// 500:
// swagger:route DELETE /admin/channels admin deleteChannels
//
// Will remove the channels to log, only works with userIds
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
// - text/plain
//
// Security:
// - api_key:
//
// Schemes: https
//
// Responses:
// 200:
// 400:
// 405:
// 500:
func (s *Server) writeChannels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "We'll see, we'll see. The winner gets tea.", http.StatusMethodNotAllowed)
return
}
if r.Method == http.MethodDelete {
var request channelsDeleteRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
http.Error(w, "ANYWAYS: "+err.Error(), http.StatusBadRequest)
return
}
s.cfg.RemoveChannels(request.Channels...)
data, err := s.helixClient.GetUsersByUserIds(request.Channels)
if err != nil {
http.Error(w, "Failed to get channel names to leave, config might be already updated", http.StatusInternalServerError)
return
}
for _, userData := range data {
s.bot.Part(userData.Login)
}
writeJSON(fmt.Sprintf("Doubters? Removed channels %v", request.Channels), http.StatusOK, w, r)
return
}
var request channelConfigsJoinRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
http.Error(w, "ANYWAYS: "+err.Error(), http.StatusBadRequest)
return
}
s.cfg.AddChannels(request.Channels...)
data, err := s.helixClient.GetUsersByUserIds(request.Channels)
if err != nil {
http.Error(w, "Failed to get channel names to join, config might be already updated", http.StatusInternalServerError)
return
}
for _, userData := range data {
s.bot.Join(userData.Login)
}
writeJSON(fmt.Sprintf("Doubters? Joined channels or already in: %v", request.Channels), http.StatusOK, w, r)
}

View File

@ -1,153 +0,0 @@
package api
import (
"errors"
"strconv"
"github.com/gempir/go-twitch-irc/v3"
)
// RandomQuoteJSON response when request a random message
type RandomChannelQuoteJSON struct {
Channel string `json:"channel"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Message string `json:"message"`
Timestamp timestamp `json:"timestamp"`
}
// swagger:route GET /channel/{channel}/random logs randomChannelLog
//
// Get a random line from the entire channel log's history
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channelid/{channelid}/random logs randomChannelLog
//
// Get a random line from the entire channel log's history
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
func (s *Server) getChannelRandomQuote(request logRequest) (*chatLog, error) {
rawMessage, err := s.fileLogger.ReadRandomMessageForChannel(request.channelid)
if err != nil {
return &chatLog{}, err
}
parsedMessage := twitch.ParseMessage(rawMessage)
chatMsg := createChatMessage(parsedMessage)
return &chatLog{Messages: []chatMessage{chatMsg}}, nil
}
// swagger:route GET /channel/{channel} logs channelLogs
//
// Get entire channel logs of current day
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channel/{channel}/{year}/{month}/{day} logs channelLogsYearMonthDay
//
// Get entire channel logs of given day
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
func (s *Server) getChannelLogs(request logRequest) (*chatLog, error) {
yearStr := request.time.year
monthStr := request.time.month
dayStr := request.time.day
year, err := strconv.Atoi(yearStr)
if err != nil {
return &chatLog{}, errors.New("invalid year")
}
month, err := strconv.Atoi(monthStr)
if err != nil {
return &chatLog{}, errors.New("invalid month")
}
day, err := strconv.Atoi(dayStr)
if err != nil {
return &chatLog{}, errors.New("invalid day")
}
logMessages, err := s.fileLogger.ReadLogForChannel(request.channelid, year, month, day)
if err != nil {
return &chatLog{}, err
}
if request.reverse {
reverseSlice(logMessages)
}
logResult := createLogResult()
for _, rawMessage := range logMessages {
logResult.Messages = append(logResult.Messages, createChatMessage(twitch.ParseMessage(rawMessage)))
}
return &logResult, nil
}
func (s *Server) getChannelLogsRange(request logRequest) (*chatLog, error) {
fromTime, toTime, err := parseFromTo(request.time.from, request.time.to, channelHourLimit)
if err != nil {
return &chatLog{}, err
}
var logMessages []string
logMessages, _ = s.fileLogger.ReadLogForChannel(request.channelid, fromTime.Year(), int(fromTime.Month()), fromTime.Day())
if fromTime.Month() != toTime.Month() {
additionalMessages, _ := s.fileLogger.ReadLogForChannel(request.channelid, toTime.Year(), int(toTime.Month()), toTime.Day())
logMessages = append(logMessages, additionalMessages...)
}
if request.reverse {
reverseSlice(logMessages)
}
logResult := createLogResult()
for _, rawMessage := range logMessages {
parsedMessage := twitch.ParseMessage(rawMessage)
switch message := parsedMessage.(type) {
case *twitch.PrivateMessage:
if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() {
continue
}
case *twitch.ClearChatMessage:
if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() {
continue
}
case *twitch.UserNoticeMessage:
if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() {
continue
}
}
logResult.Messages = append(logResult.Messages, createChatMessage(parsedMessage))
}
return &logResult, nil
}

View File

@ -1,222 +0,0 @@
// Package classification justlog API
//
// https://github.com/gempir/justlog
//
// Schemes: https
// BasePath: /
//
// Consumes:
// - application/json
// - application/xml
//
// Produces:
// - application/json
// - text/plain
//
// SecurityDefinitions:
// api_key:
// type: apiKey
// name: X-Api-Key
// in: header
//
// swagger:meta
package api
type LogParams struct {
// in: query
Json string `json:"json"`
// in: query
Reverse string `json:"reverse"`
// in: query
From int32 `json:"from"`
// in: query
To int32 `json:"to"`
}
//swagger:parameters channelUserLogsRandom
type ChannelUserLogsRandomParams struct {
// in: path
Channel string `json:"channel"`
// in: path
Username string `json:"username"`
LogParams
}
//swagger:parameters channelUserLogs
type ChannelUserLogsParams struct {
// in: path
Channel string `json:"channel"`
// in: path
Username string `json:"username"`
LogParams
}
//swagger:parameters channelUserLogsYearMonth
type ChannelUserLogsYearMonthParams struct {
// in: path
Channel string `json:"channel"`
// in: path
Username string `json:"username"`
// in: path
Year string `json:"year"`
// in: path
Month string `json:"month"`
LogParams
}
//swagger:parameters channelLogs
type ChannelLogsParams struct {
// in: path
Channel string `json:"channel"`
LogParams
}
//swagger:parameters channelLogsYearMonthDay
type ChannelLogsYearMonthDayParams struct {
// in: path
Channel string `json:"channel"`
// in: path
Year string `json:"year"`
// in: path
Month string `json:"month"`
// in: path
Day string `json:"day"`
LogParams
}
//swagger:parameters channelIdUserIdLogsRandom
type ChannelIdUserIdLogsRandomParams struct {
// in: path
ChannelId string `json:"channelid"`
// in: path
UserId string `json:"userid"`
LogParams
}
//swagger:parameters channelIdUserIdLogs
type ChannelIdUserIdLogsParams struct {
// in: path
ChannelId string `json:"channelid"`
// in: path
UserId string `json:"userid"`
LogParams
}
//swagger:parameters channelIdUserIdLogsYearMonth
type ChannelIdUserIdLogsYearMonthParams struct {
// in: path
ChannelId string `json:"channelid"`
// in: path
UserId string `json:"userid"`
// in: path
Year string `json:"year"`
// in: path
Month string `json:"month"`
LogParams
}
//swagger:parameters channelIdLogs
type ChannelIdLogsParams struct {
// in: path
Channel string `json:"channelid"`
LogParams
}
//swagger:parameters channelIdLogsYearMonthDay
type ChannelIdLogsYearMonthDayParams struct {
// in: path
Channel string `json:"channel"`
// in: path
Year string `json:"year"`
// in: path
Month string `json:"month"`
// in: path
Day string `json:"day"`
LogParams
}
//swagger:parameters channelIdUserLogsRandom
type ChannelIdUserLogsRandomParams struct {
// in: path
ChannelId string `json:"channelid"`
// in: path
Username string `json:"username"`
LogParams
}
//swagger:parameters channelIdUserLogs
type ChannelIdUserLogsParams struct {
// in: path
ChannelId string `json:"channelid"`
// in: path
Username string `json:"username"`
LogParams
}
//swagger:parameters channelIdUserLogsYearMonth
type ChannelIdUserLogsYearMonthParams struct {
// in: path
ChannelId string `json:"channelid"`
// in: path
Username string `json:"username"`
// in: path
Year string `json:"year"`
// in: path
Month string `json:"month"`
LogParams
}
//swagger:parameters channelUserIdLogsRandom
type ChannelUserIdLogsRandomParams struct {
// in: path
Channel string `json:"channel"`
// in: path
UserId string `json:"userid"`
LogParams
}
//swagger:parameters channelUserIdLogs
type ChannelUserIdLogsParams struct {
// in: path
Channel string `json:"channel"`
// in: path
UserId string `json:"userid"`
LogParams
}
//swagger:parameters channelUserIdLogsYearMonth
type ChannelUserIdLogsYearMonthParams struct {
// in: path
Channel string `json:"channel"`
// in: path
Userid string `json:"userid"`
// in: path
Year string `json:"year"`
// in: path
Month string `json:"month"`
LogParams
}
// swagger:parameters addChannels
type AddChannelsParameters struct {
// in:body
Body channelConfigsJoinRequest
}
// swagger:parameters deleteChannels
type DeleteChannelsParameters struct {
// in:body
Body channelsDeleteRequest
}
//swagger:parameters list
type ListLogsParams struct {
// in: query
Channel string `json:"channel"`
// in: query
Username string `json:"username"`
// in: query
ChannelId string `json:"channelid"`
// in: query
Userid string `json:"userid"`
}

View File

@ -1,190 +0,0 @@
package api
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
type logRequest struct {
channel string
user string
channelid string
userid string
time logTime
reverse bool
responseType string
redirectPath string
isUserRequest bool
isChannelRequest bool
}
// userRandomMessageRequest /channel/pajlada/user/gempir/random
type logTime struct {
from string
to string
year string
month string
day string
random bool
}
var (
pathRegex = regexp.MustCompile(`\/(channel|channelid)\/(\w+)(?:\/(user|userid)\/(\w+))?(?:(?:\/(\d{4})\/(\d{1,2})(?:\/(\d{1,2}))?)|(?:\/(random)))?`)
)
func (s *Server) newLogRequestFromURL(r *http.Request) (logRequest, error) {
path := r.URL.EscapedPath()
if path != strings.ToLower(path) {
return logRequest{redirectPath: fmt.Sprintf("%s?%s", strings.ToLower(path), r.URL.Query().Encode())}, nil
}
if !strings.HasPrefix(path, "/channel") && !strings.HasPrefix(path, "/channelid") {
return logRequest{}, errors.New("route not found")
}
url := strings.TrimRight(path, "/")
matches := pathRegex.FindAllStringSubmatch(url, -1)
if len(matches) == 0 || len(matches[0]) < 5 {
return logRequest{}, errors.New("route not found")
}
request := logRequest{
time: logTime{},
}
params := []string{}
for _, match := range matches[0] {
if match != "" {
params = append(params, match)
}
}
request.isUserRequest = len(params) > 4 && (params[3] == "user" || params[3] == "userid")
request.isChannelRequest = len(params) < 4 || (len(params) >= 4 && params[3] != "user" && params[3] != "userid")
if params[1] == "channel" {
request.channel = params[2]
}
if params[1] == "channelid" {
request.channelid = params[2]
}
if request.isUserRequest && params[3] == "user" {
request.user = params[4]
}
if request.isUserRequest && params[3] == "userid" {
request.userid = params[4]
}
var err error
request, err = s.fillIds(request)
if err != nil {
log.Error(err)
return logRequest{}, nil
}
if request.isUserRequest && len(params) == 7 {
request.time.year = params[5]
request.time.month = params[6]
} else if request.isUserRequest && len(params) == 8 {
return logRequest{}, errors.New("route not found")
} else if request.isChannelRequest && len(params) == 6 {
request.time.year = params[3]
request.time.month = params[4]
request.time.day = params[5]
} else if request.isUserRequest && len(params) == 6 && params[5] == "random" {
request.time.random = true
} else if request.isChannelRequest && len(params) == 4 && params[3] == "random" {
request.time.random = true
} else {
if request.isChannelRequest {
request.time.year = fmt.Sprintf("%d", time.Now().Year())
request.time.month = fmt.Sprintf("%d", time.Now().Month())
} else {
year, month, err := s.fileLogger.GetLastLogYearAndMonthForUser(request.channelid, request.userid)
if err == nil {
request.time.year = fmt.Sprintf("%d", year)
request.time.month = fmt.Sprintf("%d", month)
} else {
request.time.year = fmt.Sprintf("%d", time.Now().Year())
request.time.month = fmt.Sprintf("%d", time.Now().Month())
}
}
timePath := request.time.year + "/" + request.time.month
if request.isChannelRequest {
request.time.day = fmt.Sprintf("%d", time.Now().Day())
timePath += "/" + request.time.day
}
query := r.URL.Query()
encodedQuery := ""
if query.Encode() != "" {
encodedQuery = "?" + query.Encode()
}
return logRequest{redirectPath: fmt.Sprintf("%s/%s%s", params[0], timePath, encodedQuery)}, nil
}
if r.URL.Query().Get("from") != "" || r.URL.Query().Get("to") != "" {
request.time.from = r.URL.Query().Get("from")
if request.time.from == "" {
request.time.from = strconv.FormatInt(time.Now().Unix(), 10)
}
request.time.to = r.URL.Query().Get("to")
if request.time.to == "" {
request.time.to = strconv.FormatInt(time.Now().Unix(), 10)
}
}
if _, ok := r.URL.Query()["reverse"]; ok {
request.reverse = true
} else {
request.reverse = false
}
if _, ok := r.URL.Query()["json"]; ok || r.URL.Query().Get("type") == "json" || r.Header.Get("Content-Type") == "application/json" {
request.responseType = responseTypeJSON
} else if _, ok := r.URL.Query()["raw"]; ok || r.URL.Query().Get("type") == "raw" {
request.responseType = responseTypeRaw
} else {
request.responseType = responseTypeText
}
return request, nil
}
func (s *Server) fillIds(request logRequest) (logRequest, error) {
usernames := []string{}
if request.channelid == "" && request.channel != "" {
usernames = append(usernames, request.channel)
}
if request.userid == "" && request.user != "" {
usernames = append(usernames, request.user)
}
ids, err := s.helixClient.GetUsersByUsernames(usernames)
if err != nil {
return request, err
}
if request.channelid == "" {
request.channelid = ids[strings.ToLower(request.channel)].ID
}
if request.userid == "" {
request.userid = ids[strings.ToLower(request.user)].ID
}
return request, nil
}

View File

@ -1,45 +0,0 @@
package api
import (
"math/rand"
"net/http"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
// swagger:route POST /optout justlog
//
// Generates optout code to use in chat
//
// Produces:
// - application/json
//
// Schemes: https
//
// Responses:
// 200: string
func (s *Server) writeOptOutCode(w http.ResponseWriter, r *http.Request) {
code := randomString(6)
s.bot.OptoutCodes.Store(code, true)
go func() {
time.Sleep(time.Second * 60)
s.bot.OptoutCodes.Delete(code)
}()
writeJSON(code, http.StatusOK, w, r)
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890")
func randomString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}

View File

@ -1,498 +0,0 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gempir/justlog/bot"
"github.com/gempir/justlog/config"
"github.com/gempir/justlog/helix"
log "github.com/sirupsen/logrus"
"github.com/gempir/go-twitch-irc/v3"
"github.com/gempir/justlog/filelog"
)
// Server api server
type Server struct {
listenAddress string
logPath string
bot *bot.Bot
cfg *config.Config
fileLogger filelog.Logger
helixClient helix.TwitchApiClient
assetsHandler http.Handler
}
// NewServer create api Server
func NewServer(cfg *config.Config, bot *bot.Bot, fileLogger filelog.Logger, helixClient helix.TwitchApiClient, assets fs.FS) Server {
build, err := fs.Sub(assets, "web/dist")
if err != nil {
log.Fatal("failed to read public assets")
}
return Server{
listenAddress: cfg.ListenAddress,
bot: bot,
logPath: cfg.LogsDirectory,
cfg: cfg,
fileLogger: fileLogger,
helixClient: helixClient,
assetsHandler: http.FileServer(http.FS(build)),
}
}
const (
responseTypeJSON = "json"
responseTypeText = "text"
responseTypeRaw = "raw"
)
var (
userHourLimit = 744.0
channelHourLimit = 24.0
)
type channel struct {
UserID string `json:"userID"`
Name string `json:"name"`
}
// swagger:model
type AllChannelsJSON struct {
Channels []channel `json:"channels"`
}
// swagger:model
type chatLog struct {
Messages []chatMessage `json:"messages"`
}
// swagger:model
type logList struct {
AvailableLogs []filelog.UserLogFile `json:"availableLogs"`
}
// swagger:model
type channelLogList struct {
AvailableLogs []filelog.ChannelLogFile `json:"availableLogs"`
}
type chatMessage struct {
Text string `json:"text"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Channel string `json:"channel"`
Timestamp timestamp `json:"timestamp"`
ID string `json:"id"`
Type twitch.MessageType `json:"type"`
Raw string `json:"raw"`
Tags map[string]string `json:"tags"`
}
// ErrorResponse a simple error response
type ErrorResponse struct {
Message string `json:"message"`
}
type timestamp struct {
time.Time
}
// Init start the server
func (s *Server) Init() {
http.Handle("/", corsHandler(http.HandlerFunc(s.route)))
log.Infof("Listening on %s", s.listenAddress)
log.Fatal(http.ListenAndServe(s.listenAddress, nil))
}
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
url := r.URL.EscapedPath()
query, err := s.fillUserids(w, r)
if err != nil {
return
}
if url == "/list" {
if s.cfg.IsOptedOut(query.Get("userid")) || s.cfg.IsOptedOut(query.Get("channelid")) {
http.Error(w, "User or channel has opted out", http.StatusForbidden)
return
}
s.writeAvailableLogs(w, r, query)
return
}
if url == "/channels" {
s.writeAllChannels(w, r)
return
}
if url == "/optout" && r.Method == http.MethodPost {
s.writeOptOutCode(w, r)
return
}
if strings.HasPrefix(url, "/admin/channels") {
success := s.authenticateAdmin(w, r)
if success {
s.writeChannels(w, r)
}
return
}
routedLogs := s.routeLogs(w, r)
if !routedLogs {
s.assetsHandler.ServeHTTP(w, r)
return
}
}
func (s *Server) fillUserids(w http.ResponseWriter, r *http.Request) (url.Values, error) {
query := r.URL.Query()
if query.Get("userid") == "" && query.Get("user") != "" {
username := strings.ToLower(query.Get("user"))
users, err := s.helixClient.GetUsersByUsernames([]string{username})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}
if len(users) == 0 {
err := fmt.Errorf("could not find users")
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return nil, err
}
query.Set("userid", users[username].ID)
}
if query.Get("channelid") == "" && query.Get("channel") != "" {
channelName := strings.ToLower(query.Get("channel"))
users, err := s.helixClient.GetUsersByUsernames([]string{channelName})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}
if len(users) == 0 {
err := fmt.Errorf("could not find users")
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return nil, err
}
query.Set("channelid", users[channelName].ID)
}
return query, nil
}
func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) bool {
request, err := s.newLogRequestFromURL(r)
if err != nil {
return false
}
if request.redirectPath != "" {
http.Redirect(w, r, request.redirectPath, http.StatusFound)
return true
}
if s.cfg.IsOptedOut(request.channelid) || s.cfg.IsOptedOut(request.userid) {
http.Error(w, "User or channel has opted out", http.StatusForbidden)
return true
}
var logs *chatLog
if request.time.random {
if request.isUserRequest {
logs, err = s.getRandomQuote(request)
} else {
logs, err = s.getChannelRandomQuote(request)
}
} else if request.time.from != "" && request.time.to != "" {
if request.isUserRequest {
logs, err = s.getUserLogsRange(request)
} else {
logs, err = s.getChannelLogsRange(request)
}
} else {
if request.isUserRequest {
logs, err = s.getUserLogs(request)
} else {
logs, err = s.getChannelLogs(request)
}
}
if err != nil {
log.Error(err)
http.Error(w, "could not load logs", http.StatusNotFound)
return true
}
// Disable content type sniffing for log output
w.Header().Set("X-Content-Type-Options", "nosniff")
currentYear := fmt.Sprintf("%d", int(time.Now().Year()))
currentMonth := fmt.Sprintf("%d", int(time.Now().Month()))
if (request.time.year != "" && request.time.month != "") && (request.time.year < currentYear || (request.time.year == currentYear && request.time.month < currentMonth)) {
writeCacheControl(w, r, time.Hour*8760)
} else {
writeCacheControlNoCache(w, r)
}
if request.responseType == responseTypeJSON {
writeJSON(logs, http.StatusOK, w, r)
return true
}
if request.responseType == responseTypeRaw {
writeRaw(logs, http.StatusOK, w, r)
return true
}
if request.responseType == responseTypeText {
writeText(logs, http.StatusOK, w, r)
return true
}
return false
}
func corsHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
} else {
w.Header().Set("Access-Control-Allow-Origin", "*")
h.ServeHTTP(w, r)
}
})
}
func contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
func reverseSlice(input []string) []string {
for i, j := 0, len(input)-1; i < j; i, j = i+1, j-1 {
input[i], input[j] = input[j], input[i]
}
return input
}
// swagger:route GET /channels justlog channels
//
// List currently logged channels
//
// Produces:
// - application/json
// - text/plain
//
// Schemes: https
//
// Responses:
// 200: AllChannelsJSON
func (s *Server) writeAllChannels(w http.ResponseWriter, r *http.Request) {
response := new(AllChannelsJSON)
response.Channels = []channel{}
users, err := s.helixClient.GetUsersByUserIds(s.cfg.Channels)
if err != nil {
log.Error(err)
http.Error(w, "Failure fetching data from twitch", http.StatusInternalServerError)
return
}
for _, user := range users {
response.Channels = append(response.Channels, channel{UserID: user.ID, Name: user.Login})
}
writeJSON(response, http.StatusOK, w, r)
}
func writeJSON(data interface{}, code int, w http.ResponseWriter, r *http.Request) {
js, err := json.Marshal(data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
w.Write(js)
}
func writeCacheControl(w http.ResponseWriter, r *http.Request, cacheDuration time.Duration) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", cacheDuration.Seconds()))
}
func writeCacheControlNoCache(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
}
func writeRaw(cLog *chatLog, code int, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(code)
for _, cMessage := range cLog.Messages {
w.Write([]byte(cMessage.Raw + "\n"))
}
}
func writeText(cLog *chatLog, code int, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(code)
for _, cMessage := range cLog.Messages {
switch cMessage.Type {
case twitch.PRIVMSG:
w.Write([]byte(fmt.Sprintf("[%s] #%s %s: %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Username, cMessage.Text)))
case twitch.CLEARCHAT:
w.Write([]byte(fmt.Sprintf("[%s] #%s %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Text)))
case twitch.USERNOTICE:
w.Write([]byte(fmt.Sprintf("[%s] #%s %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Text)))
}
}
}
func (t timestamp) MarshalJSON() ([]byte, error) {
return []byte("\"" + t.UTC().Format(time.RFC3339) + "\""), nil
}
func (t *timestamp) UnmarshalJSON(data []byte) error {
goTime, err := time.Parse(time.RFC3339, strings.TrimSuffix(strings.TrimPrefix(string(data[:]), "\""), "\""))
if err != nil {
return err
}
*t = timestamp{
goTime,
}
return nil
}
func createLogResult() chatLog {
return chatLog{Messages: []chatMessage{}}
}
func parseFromTo(from, to string, limit float64) (time.Time, time.Time, error) {
var fromTime time.Time
var toTime time.Time
if from == "" && to == "" {
fromTime = time.Now().AddDate(0, -1, 0)
toTime = time.Now()
} else if from == "" && to != "" {
var err error
toTime, err = parseTimestamp(to)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse to timestamp: %s", err)
}
fromTime = toTime.AddDate(0, -1, 0)
} else if from != "" && to == "" {
var err error
fromTime, err = parseTimestamp(from)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse from timestamp: %s", err)
}
toTime = fromTime.AddDate(0, 1, 0)
} else {
var err error
fromTime, err = parseTimestamp(from)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse from timestamp: %s", err)
}
toTime, err = parseTimestamp(to)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse to timestamp: %s", err)
}
if toTime.Sub(fromTime).Hours() > limit {
return fromTime, toTime, errors.New("Timespan too big")
}
}
return fromTime, toTime, nil
}
func createChatMessage(parsedMessage twitch.Message) chatMessage {
switch message := parsedMessage.(type) {
case *twitch.PrivateMessage:
return chatMessage{
Timestamp: timestamp{message.Time},
Username: message.User.Name,
DisplayName: message.User.DisplayName,
Text: message.Message,
Type: message.Type,
Channel: message.Channel,
Raw: message.Raw,
ID: message.ID,
Tags: message.Tags,
}
case *twitch.ClearChatMessage:
return chatMessage{
Timestamp: timestamp{message.Time},
Username: message.TargetUsername,
DisplayName: message.TargetUsername,
Text: buildClearChatMessageText(*message),
Type: message.Type,
Channel: message.Channel,
Raw: message.Raw,
Tags: message.Tags,
}
case *twitch.UserNoticeMessage:
return chatMessage{
Timestamp: timestamp{message.Time},
Username: message.User.Name,
DisplayName: message.User.DisplayName,
Text: message.SystemMsg + " " + message.Message,
Type: message.Type,
Channel: message.Channel,
Raw: message.Raw,
ID: message.ID,
Tags: message.Tags,
}
}
return chatMessage{}
}
func parseTimestamp(timestamp string) (time.Time, error) {
i, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return time.Now(), err
}
return time.Unix(i, 0), nil
}
func buildClearChatMessageText(message twitch.ClearChatMessage) string {
if message.BanDuration == 0 {
return fmt.Sprintf("%s has been banned", message.TargetUsername)
}
return fmt.Sprintf("%s has been timed out for %d seconds", message.TargetUsername, message.BanDuration)
}

View File

@ -1,265 +0,0 @@
package api
import (
"fmt"
"net/http"
"net/url"
"time"
"github.com/gempir/go-twitch-irc/v3"
)
// RandomQuoteJSON response when request a random message
type RandomQuoteJSON struct {
Channel string `json:"channel"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Message string `json:"message"`
Timestamp timestamp `json:"timestamp"`
}
// swagger:route GET /channel/{channel}/user/{username}/random logs channelUserLogsRandom
//
// Get a random line from a user in a given channel
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channelid/{channelid}/userid/{userid}/random logs channelIdUserIdLogsRandom
//
// Get a random line from a user in a given channel
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channelid/{channelid}/user/{user}/random logs channelIdUserLogsRandom
//
// Get a random line from a user in a given channel
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channel/{channel}/userid/{userid}/random logs channelUserIdLogsRandom
//
// Get a random line from a user in a given channel
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
func (s *Server) getRandomQuote(request logRequest) (*chatLog, error) {
rawMessage, err := s.fileLogger.ReadRandomMessageForUser(request.channelid, request.userid)
if err != nil {
return &chatLog{}, err
}
parsedMessage := twitch.ParseMessage(rawMessage)
chatMsg := createChatMessage(parsedMessage)
return &chatLog{Messages: []chatMessage{chatMsg}}, nil
}
// swagger:route GET /list logs list
//
// Lists available logs of a user or channel, channel response also includes the day. OpenAPI 2 does not support multiple responses with the same http code right now.
//
// Produces:
// - application/json
// - text/plain
//
// Schemes: https
//
// Responses:
// 200: logList
func (s *Server) writeAvailableLogs(w http.ResponseWriter, r *http.Request, q url.Values) {
channelid := q.Get("channelid")
userid := q.Get("userid")
if userid == "" {
logs, err := s.fileLogger.GetAvailableLogsForChannel(channelid)
if err != nil {
http.Error(w, "failed to get available channel logs: "+err.Error(), http.StatusNotFound)
return
}
writeCacheControl(w, r, time.Hour)
writeJSON(&channelLogList{logs}, http.StatusOK, w, r)
return
}
logs, err := s.fileLogger.GetAvailableLogsForUser(channelid, userid)
if err != nil {
http.Error(w, "failed to get available user logs: "+err.Error(), http.StatusNotFound)
return
}
writeCacheControl(w, r, time.Hour)
writeJSON(&logList{logs}, http.StatusOK, w, r)
}
// swagger:route GET /channel/{channel}/user/{username} logs channelUserLogs
//
// Get user logs in channel of current month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channelid/{channelid}/userid/{userid} logs channelIdUserIdLogs
//
// Get user logs in channel of current month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channelid/{channelid}/user/{username} logs channelIdUserLogs
//
// Get user logs in channel of current month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channel/{channel}/userid/{userid} logs channelUserIdLogs
//
// Get user logs in channel of current month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channel/{channel}/user/{username}/{year}/{month} logs channelUserLogsYearMonth
//
// Get user logs in channel of given year month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channelid/{channelid}/userid/{userid}/{year}/{month} logs channelIdUserIdLogsYearMonth
//
// Get user logs in channel of given year month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channelid/{channelid}/user/{username}/{year}/{month} logs channelIdUserLogsYearMonth
//
// Get user logs in channel of given year month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
// swagger:route GET /channel/{channel}/userid/{userid}/{year}/{month} logs channelUserIdLogsYearMonth
//
// Get user logs in channel of given year month
//
// Produces:
// - application/json
// - text/plain
//
// Responses:
// 200: chatLog
func (s *Server) getUserLogs(request logRequest) (*chatLog, error) {
logMessages, err := s.fileLogger.ReadLogForUser(request.channelid, request.userid, request.time.year, request.time.month)
if err != nil {
return &chatLog{}, err
}
if request.reverse {
reverseSlice(logMessages)
}
logResult := createLogResult()
for _, rawMessage := range logMessages {
logResult.Messages = append(logResult.Messages, createChatMessage(twitch.ParseMessage(rawMessage)))
}
return &logResult, nil
}
func (s *Server) getUserLogsRange(request logRequest) (*chatLog, error) {
fromTime, toTime, err := parseFromTo(request.time.from, request.time.to, userHourLimit)
if err != nil {
return &chatLog{}, err
}
var logMessages []string
logMessages, _ = s.fileLogger.ReadLogForUser(request.channelid, request.userid, fmt.Sprintf("%d", fromTime.Year()), fmt.Sprintf("%d", int(fromTime.Month())))
if fromTime.Month() != toTime.Month() {
additionalMessages, _ := s.fileLogger.ReadLogForUser(request.channelid, request.userid, fmt.Sprint(toTime.Year()), fmt.Sprint(toTime.Month()))
logMessages = append(logMessages, additionalMessages...)
}
if request.reverse {
reverseSlice(logMessages)
}
logResult := createLogResult()
for _, rawMessage := range logMessages {
parsedMessage := twitch.ParseMessage(rawMessage)
switch message := parsedMessage.(type) {
case *twitch.PrivateMessage:
if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() {
continue
}
case *twitch.ClearChatMessage:
if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() {
continue
}
case *twitch.UserNoticeMessage:
if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() {
continue
}
}
logResult.Messages = append(logResult.Messages, createChatMessage(parsedMessage))
}
return &logResult, nil
}

View File

@ -1,35 +0,0 @@
package archiver
import (
"time"
)
func NewArchiver(logPath string) *Archiver {
return &Archiver{
logPath: logPath,
workQueue: make(chan string),
}
}
type Archiver struct {
logPath string
workQueue chan string
}
func (a *Archiver) Boot() {
go a.startScanner()
a.startConsumer()
}
func (a *Archiver) startConsumer() {
for task := range a.workQueue {
a.gzipFile(task)
}
}
func (a *Archiver) startScanner() {
for {
a.scanLogPath()
time.Sleep(time.Second * 60)
}
}

View File

@ -1,47 +0,0 @@
package archiver
import (
"bufio"
"compress/gzip"
"io/ioutil"
"os"
log "github.com/sirupsen/logrus"
)
func (a *Archiver) gzipFile(filePath string) {
file, err := os.Open(filePath)
if err != nil {
log.Errorf("File not found: %s Error: %s", filePath, err.Error())
return
}
defer file.Close()
log.Infof("Archiving: %s", filePath)
reader := bufio.NewReader(file)
content, err := ioutil.ReadAll(reader)
if err != nil {
log.Errorf("Failure reading file: %s Error: %s", filePath, err.Error())
return
}
gzipFile, err := os.Create(filePath + ".gz")
if err != nil {
log.Errorf("Failure creating file: %s.gz Error: %s", filePath, err.Error())
return
}
defer gzipFile.Close()
w := gzip.NewWriter(gzipFile)
_, err = w.Write(content)
if err != nil {
log.Errorf("Failure writing content in file: %s.gz Error: %s", filePath, err.Error())
}
w.Close()
err = os.Remove(filePath)
if err != nil {
log.Errorf("Failure deleting file: %s Error: %s", filePath, err.Error())
}
}

View File

@ -1,103 +0,0 @@
package archiver
import (
log "github.com/sirupsen/logrus"
"io/ioutil"
"os"
"strconv"
"strings"
"time"
)
func (a *Archiver) scanLogPath() {
channelFiles, err := ioutil.ReadDir(a.logPath)
if err != nil {
log.Error(err)
}
for _, channelId := range channelFiles {
if channelId.IsDir() {
yearFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name())
if err != nil {
log.Error(err)
}
yearFiles = a.filterFiles(yearFiles)
for _, year := range yearFiles {
if year.IsDir() {
monthFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name() + "/" + year.Name())
if err != nil {
log.Error(err)
}
monthFiles = a.filterFiles(monthFiles)
for _, month := range monthFiles {
if month.IsDir() {
dayFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name())
if err != nil {
log.Error(err)
}
dayFiles = a.filterFiles(dayFiles)
for _, dayOrUserId := range dayFiles {
if dayOrUserId.IsDir() {
channelLogFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name() + "/" + dayOrUserId.Name())
if err != nil {
log.Error(err)
}
channelLogFiles = a.filterFiles(channelLogFiles)
for _, channelLogFile := range channelLogFiles {
if strings.HasSuffix(channelLogFile.Name(), ".txt") {
dayInt, err := strconv.Atoi(dayOrUserId.Name())
if err != nil {
log.Errorf("Failure converting day to int in scanner %s", err.Error())
continue
}
if dayInt == int(time.Now().Day()) {
continue
}
a.workQueue <- a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name() + "/" + dayOrUserId.Name() + "/" + channelLogFile.Name()
}
}
} else if strings.HasSuffix(dayOrUserId.Name(), ".txt") {
monthInt, err := strconv.Atoi(month.Name())
if err != nil {
log.Errorf("Failure converting month to int in scanner %s", err.Error())
continue
}
if monthInt == int(time.Now().Month()) {
continue
}
a.workQueue <- a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name() + "/" + dayOrUserId.Name()
}
}
}
}
}
}
}
}
}
func (a *Archiver) filterFiles(files []os.FileInfo) []os.FileInfo {
var result []os.FileInfo
for _, file := range files {
if !strings.HasPrefix(file.Name(), ".") {
result = append(result, file)
}
}
return result
}

View File

@ -1,164 +0,0 @@
package bot
import (
"fmt"
"strings"
twitch "github.com/gempir/go-twitch-irc/v3"
"github.com/gempir/justlog/humanize"
log "github.com/sirupsen/logrus"
)
const (
commandPrefix = "!justlog"
errNoUsernames = ", at least 1 username has to be provided. multiple usernames have to be separated with a space"
errRequestingUserIDs = ", something went wrong requesting the userids"
)
func (b *Bot) handlePrivateMessageCommands(message twitch.PrivateMessage) {
if !strings.HasPrefix(strings.ToLower(message.Message), commandPrefix) {
return
}
args := strings.Fields(message.Message[len(commandPrefix):])
if len(args) < 1 {
return
}
commandName := args[0]
args = args[1:]
switch commandName {
case "status":
if !contains(b.cfg.Admins, message.User.Name) {
return
}
uptime := humanize.TimeSince(b.startTime)
b.Say(message.Channel, fmt.Sprintf("%s, uptime: %s", message.User.DisplayName, uptime))
case "join":
if !contains(b.cfg.Admins, message.User.Name) {
return
}
b.handleJoin(message, args)
case "part":
if !contains(b.cfg.Admins, message.User.Name) {
return
}
b.handlePart(message, args)
case "optout":
b.handleOptOut(message, args)
case "optin":
if !contains(b.cfg.Admins, message.User.Name) {
return
}
b.handleOptIn(message, args)
}
}
// Commands
func (b *Bot) handleJoin(message twitch.PrivateMessage, args []string) {
if len(args) < 1 {
b.Say(message.Channel, message.User.DisplayName+errNoUsernames)
return
}
users, err := b.helixClient.GetUsersByUsernames(args)
if err != nil {
log.Error(err)
b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs)
}
ids := []string{}
for _, user := range users {
ids = append(ids, user.ID)
b.Join(user.Login)
}
b.cfg.AddChannels(ids...)
b.Say(message.Channel, fmt.Sprintf("%s, added channels: %v", message.User.DisplayName, ids))
}
func (b *Bot) handlePart(message twitch.PrivateMessage, args []string) {
if len(args) < 1 {
b.Say(message.Channel, message.User.DisplayName+errNoUsernames)
return
}
users, err := b.helixClient.GetUsersByUsernames(args)
if err != nil {
log.Error(err)
b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs)
}
ids := []string{}
for _, user := range users {
ids = append(ids, user.ID)
b.Part(user.Login)
}
b.cfg.RemoveChannels(ids...)
b.Say(message.Channel, fmt.Sprintf("%s, removed channels: %v", message.User.DisplayName, ids))
}
func (b *Bot) handleOptOut(message twitch.PrivateMessage, args []string) {
if len(args) < 1 {
b.Say(message.Channel, message.User.DisplayName+errNoUsernames)
return
}
if _, ok := b.OptoutCodes.LoadAndDelete(args[0]); ok {
b.cfg.OptOutUsers(message.User.ID)
b.Say(message.Channel, fmt.Sprintf("%s, opted you out", message.User.DisplayName))
return
}
if !contains(b.cfg.Admins, message.User.Name) {
return
}
users, err := b.helixClient.GetUsersByUsernames(args)
if err != nil {
log.Error(err)
b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs)
}
ids := []string{}
for _, user := range users {
ids = append(ids, user.ID)
}
b.cfg.OptOutUsers(ids...)
b.Say(message.Channel, fmt.Sprintf("%s, opted out channels: %v", message.User.DisplayName, ids))
}
func (b *Bot) handleOptIn(message twitch.PrivateMessage, args []string) {
if len(args) < 1 {
b.Say(message.Channel, message.User.DisplayName+errNoUsernames)
return
}
users, err := b.helixClient.GetUsersByUsernames(args)
if err != nil {
log.Error(err)
b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs)
}
ids := []string{}
for _, user := range users {
ids = append(ids, user.ID)
}
b.cfg.RemoveOptOut(ids...)
b.Say(message.Channel, fmt.Sprintf("%s, opted in channels: %v", message.User.DisplayName, ids))
}
// Utilities
func contains(arr []string, str string) bool {
for _, x := range arr {
if x == str {
return true
}
}
return false
}

View File

@ -1,255 +0,0 @@
package bot
import (
"math/rand"
"strings"
"sync"
"time"
"github.com/gempir/justlog/config"
"github.com/gempir/justlog/filelog"
expiremap "github.com/nursik/go-expire-map"
twitch "github.com/gempir/go-twitch-irc/v3"
"github.com/gempir/justlog/helix"
log "github.com/sirupsen/logrus"
)
// Bot basic logging bot
type Bot struct {
startTime time.Time
cfg *config.Config
helixClient helix.TwitchApiClient
logger filelog.Logger
worker []*worker
channels map[string]helix.UserData
clearchats sync.Map
OptoutCodes sync.Map
msgMap *expiremap.ExpireMap
}
type worker struct {
client *twitch.Client
joinedChannels map[string]bool
}
func newWorker(client *twitch.Client) *worker {
return &worker{
client: client,
joinedChannels: map[string]bool{},
}
}
// NewBot create new bot instance
func NewBot(cfg *config.Config, helixClient helix.TwitchApiClient, fileLogger filelog.Logger) *Bot {
channels, err := helixClient.GetUsersByUserIds(cfg.Channels)
if err != nil {
log.Fatalf("[bot] failed to load configured channels %s", err.Error())
}
return &Bot{
cfg: cfg,
helixClient: helixClient,
logger: fileLogger,
channels: channels,
worker: []*worker{},
OptoutCodes: sync.Map{},
msgMap: expiremap.New(),
}
}
func (b *Bot) Say(channel, text string) {
randomIndex := rand.Intn(len(b.worker))
b.worker[randomIndex].client.Say(channel, text)
}
// Connect startup the logger and bot
func (b *Bot) Connect() {
b.startTime = time.Now()
client := b.newClient()
go b.startJoinLoop()
if strings.HasPrefix(b.cfg.Username, "justinfan") {
log.Info("[bot] joining as anonymous user")
} else {
log.Info("[bot] joining as user " + b.cfg.Username)
}
defer b.msgMap.Close()
log.Fatal(client.Connect())
}
// constantly join channels to rejoin some channels that got unbanned over time
func (b *Bot) startJoinLoop() {
for {
for _, channel := range b.channels {
b.Join(channel.Login)
}
time.Sleep(time.Hour * 1)
log.Info("[bot] running hourly join loop")
}
}
func (b *Bot) Part(channelNames ...string) {
for _, channelName := range channelNames {
log.Info("[bot] leaving " + channelName)
for _, worker := range b.worker {
worker.client.Depart(channelName)
}
}
}
func (b *Bot) Join(channelNames ...string) {
for _, channel := range channelNames {
channel = strings.ToLower(channel)
joined := false
for _, worker := range b.worker {
if _, ok := worker.joinedChannels[channel]; ok {
// already joined but join again in case it was a temporary ban
worker.client.Join(channel)
joined = true
}
}
for _, worker := range b.worker {
if len(worker.joinedChannels) < 50 {
log.Info("[bot] joining " + channel)
worker.client.Join(channel)
worker.joinedChannels[channel] = true
joined = true
break
}
}
if !joined {
client := b.newClient()
go client.Connect()
b.Join(channel)
}
}
}
func (b *Bot) newClient() *twitch.Client {
client := twitch.NewClient(b.cfg.Username, "oauth:"+b.cfg.OAuth)
if b.cfg.BotVerified {
client.SetJoinRateLimiter(twitch.CreateVerifiedRateLimiter())
}
b.worker = append(b.worker, newWorker(client))
log.Infof("[bot] creating new twitch connection, new total: %d", len(b.worker))
client.OnPrivateMessage(b.handlePrivateMessage)
client.OnUserNoticeMessage(b.handleUserNotice)
client.OnClearChatMessage(b.handleClearChat)
return client
}
func (b *Bot) handlePrivateMessage(message twitch.PrivateMessage) {
if _, ok := b.msgMap.Get(message.ID); ok {
return
}
b.msgMap.Set(message.ID, true, time.Second*3)
b.handlePrivateMessageCommands(message)
if b.cfg.IsOptedOut(message.User.ID) || b.cfg.IsOptedOut(message.RoomID) {
return
}
go func() {
err := b.logger.LogPrivateMessageForUser(message.User, message)
if err != nil {
log.Error(err.Error())
}
}()
go func() {
err := b.logger.LogPrivateMessageForChannel(message)
if err != nil {
log.Error(err.Error())
}
}()
}
func (b *Bot) handleUserNotice(message twitch.UserNoticeMessage) {
if _, ok := b.msgMap.Get(message.ID); ok {
return
}
b.msgMap.Set(message.ID, true, time.Second*3)
if b.cfg.IsOptedOut(message.User.ID) || b.cfg.IsOptedOut(message.RoomID) {
return
}
go func() {
err := b.logger.LogUserNoticeMessageForUser(message.User.ID, message)
if err != nil {
log.Error(err.Error())
}
}()
if _, ok := message.Tags["msg-param-recipient-id"]; ok {
go func() {
err := b.logger.LogUserNoticeMessageForUser(message.Tags["msg-param-recipient-id"], message)
if err != nil {
log.Error(err.Error())
}
}()
}
go func() {
err := b.logger.LogUserNoticeMessageForChannel(message)
if err != nil {
log.Error(err.Error())
}
}()
}
func (b *Bot) handleClearChat(message twitch.ClearChatMessage) {
if b.cfg.IsOptedOut(message.TargetUserID) || b.cfg.IsOptedOut(message.RoomID) {
return
}
if message.BanDuration == 0 {
count, ok := b.clearchats.Load(message.RoomID)
if !ok {
count = 0
}
newCount := count.(int) + 1
b.clearchats.Store(message.RoomID, newCount)
go func() {
time.Sleep(time.Second * 1)
count, ok := b.clearchats.Load(message.RoomID)
if ok {
b.clearchats.Store(message.RoomID, count.(int)-1)
}
}()
if newCount > 50 {
if newCount == 51 {
log.Infof("Stopped recording CLEARCHAT permabans in: %s", message.Channel)
}
return
}
}
go func() {
err := b.logger.LogClearchatMessageForUser(message.TargetUserID, message)
if err != nil {
log.Error(err.Error())
}
}()
go func() {
err := b.logger.LogClearchatMessageForChannel(message)
if err != nil {
log.Error(err.Error())
}
}()
}

View File

@ -1,13 +0,0 @@
{
"admins": ["gempir"],
"logsDirectory": "./logs",
"adminAPIKey": "noshot",
"username": "gempbot",
"oauth": "oauthtokenforchat",
"botVerified": true,
"clientID": "mytwitchclientid",
"clientSecret": "mysecret",
"logLevel": "info",
"channels": ["77829817", "11148817"],
"archive": true
}

View File

@ -1,176 +0,0 @@
package config
import (
"encoding/json"
"io/ioutil"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
// Config application configuration
type Config struct {
configFile string
configFilePermissions os.FileMode
BotVerified bool `json:"botVerified"`
LogsDirectory string `json:"logsDirectory"`
Archive bool `json:"archive"`
AdminAPIKey string `json:"adminAPIKey"`
Username string `json:"username"`
OAuth string `json:"oauth"`
ListenAddress string `json:"listenAddress"`
Admins []string `json:"admins"`
Channels []string `json:"channels"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
LogLevel string `json:"logLevel"`
OptOut map[string]bool `json:"optOut"`
}
// NewConfig create configuration from file
func NewConfig(filePath string) *Config {
cfg := loadConfiguration(filePath)
log.Info("Loaded config from " + filePath)
return cfg
}
// AddChannels adds channels to the config
func (cfg *Config) AddChannels(channelIDs ...string) {
for _, id := range channelIDs {
cfg.Channels = appendIfMissing(cfg.Channels, id)
}
cfg.persistConfig()
}
// OptOutUsers will opt out a user
func (cfg *Config) OptOutUsers(userIDs ...string) {
for _, id := range userIDs {
cfg.OptOut[id] = true
}
cfg.persistConfig()
}
// IsOptedOut check if a user is opted out
func (cfg *Config) IsOptedOut(userID string) bool {
_, ok := cfg.OptOut[userID]
return ok
}
// AddChannels remove user from opt out
func (cfg *Config) RemoveOptOut(userIDs ...string) {
for _, id := range userIDs {
delete(cfg.OptOut, id)
}
cfg.persistConfig()
}
// RemoveChannels removes channels from the config
func (cfg *Config) RemoveChannels(channelIDs ...string) {
channels := cfg.Channels
for i, channel := range channels {
for _, removeChannel := range channelIDs {
if channel == removeChannel {
channels[i] = channels[len(channels)-1]
channels[len(channels)-1] = ""
channels = channels[:len(channels)-1]
}
}
}
cfg.Channels = channels
cfg.persistConfig()
}
func appendIfMissing(slice []string, i string) []string {
for _, ele := range slice {
if ele == i {
return slice
}
}
return append(slice, i)
}
func (cfg *Config) persistConfig() {
fileContents, err := json.MarshalIndent(*cfg, "", " ")
if err != nil {
log.Error(err)
return
}
err = ioutil.WriteFile(cfg.configFile, fileContents, cfg.configFilePermissions)
if err != nil {
log.Error(err)
}
}
func loadConfiguration(filePath string) *Config {
// setup defaults
cfg := Config{
configFile: filePath,
LogsDirectory: "./logs",
ListenAddress: ":8025",
Username: "justinfan777777",
OAuth: "oauth:777777777",
Channels: []string{},
Admins: []string{"gempir"},
LogLevel: "info",
Archive: true,
OptOut: map[string]bool{},
}
info, err := os.Stat(filePath)
if err != nil {
log.Fatal(err)
}
cfg.configFilePermissions = info.Mode()
configFile, err := os.Open(filePath)
if err != nil {
log.Fatal(err)
}
defer configFile.Close()
jsonParser := json.NewDecoder(configFile)
err = jsonParser.Decode(&cfg)
if err != nil {
log.Fatal(err)
}
// normalize
cfg.LogsDirectory = strings.TrimSuffix(cfg.LogsDirectory, "/")
cfg.OAuth = strings.TrimPrefix(cfg.OAuth, "oauth:")
cfg.LogLevel = strings.ToLower(cfg.LogLevel)
cfg.setupLogger()
// ensure required
if cfg.ClientID == "" {
log.Fatal("No clientID specified")
}
return &cfg
}
func (cfg *Config) setupLogger() {
switch cfg.LogLevel {
case "fatal":
log.SetLevel(log.FatalLevel)
case "panic":
log.SetLevel(log.PanicLevel)
case "error":
log.SetLevel(log.ErrorLevel)
case "warn":
log.SetLevel(log.WarnLevel)
case "info":
log.SetLevel(log.InfoLevel)
case "debug":
log.SetLevel(log.DebugLevel)
}
}

View File

View File

@ -1,191 +0,0 @@
package filelog
import (
"bufio"
"compress/gzip"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"os"
"strings"
"github.com/gempir/go-twitch-irc/v3"
log "github.com/sirupsen/logrus"
)
func (l *FileLogger) LogPrivateMessageForChannel(message twitch.PrivateMessage) error {
year := message.Time.Year()
month := int(message.Time.Month())
day := message.Time.Day()
err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/%d", message.RoomID, year, month, day), 0750)
if err != nil {
return err
}
filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", message.RoomID, year, month, day)
file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(message.Raw + "\n"); err != nil {
return err
}
return nil
}
func (l *FileLogger) LogClearchatMessageForChannel(message twitch.ClearChatMessage) error {
year := message.Time.Year()
month := int(message.Time.Month())
day := message.Time.Day()
err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/%d", message.RoomID, year, month, day), 0750)
if err != nil {
return err
}
filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", message.RoomID, year, month, day)
file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(message.Raw + "\n"); err != nil {
return err
}
return nil
}
func (l *FileLogger) LogUserNoticeMessageForChannel(message twitch.UserNoticeMessage) error {
year := message.Time.Year()
month := int(message.Time.Month())
day := message.Time.Day()
err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/%d", message.RoomID, year, month, day), 0750)
if err != nil {
return err
}
filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", message.RoomID, year, month, day)
file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(message.Raw + "\n"); err != nil {
return err
}
return nil
}
func (l *FileLogger) ReadLogForChannel(channelID string, year int, month int, day int) ([]string, error) {
filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", channelID, year, month, day)
if _, err := os.Stat(filename); err != nil {
filename = filename + ".gz"
}
f, err := os.Open(filename)
if err != nil {
return []string{}, errors.New("file not found: " + filename)
}
defer f.Close()
var reader io.Reader
if strings.HasSuffix(filename, ".gz") {
gz, err := gzip.NewReader(f)
if err != nil {
log.Error(err)
return []string{}, errors.New("file gzip not readable")
}
reader = gz
} else {
reader = f
}
scanner := bufio.NewScanner(reader)
if err != nil {
log.Error(err)
return []string{}, errors.New("file not readable")
}
content := []string{}
for scanner.Scan() {
line := scanner.Text()
content = append(content, line)
}
return content, nil
}
func (l *FileLogger) ReadRandomMessageForChannel(channelID string) (string, error) {
var dayFileList []string
var lines []string
if channelID == "" {
return "", errors.New("missing channelID")
}
years, _ := ioutil.ReadDir(l.logPath + "/" + channelID)
for _, yearDir := range years {
year := yearDir.Name()
months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/")
for _, monthDir := range months {
month := monthDir.Name()
possibleDays := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}
for _, day := range possibleDays {
dayDirPath := l.logPath + "/" + channelID + "/" + year + "/" + month + "/" + fmt.Sprint(day)
logFiles, err := ioutil.ReadDir(dayDirPath)
if err != nil {
continue
}
for _, logFile := range logFiles {
logFilePath := dayDirPath + "/" + logFile.Name()
dayFileList = append(dayFileList, logFilePath)
}
}
}
}
if len(dayFileList) < 1 {
return "", errors.New("no log found")
}
randomDayIndex := rand.Intn(len(dayFileList))
randomDayPath := dayFileList[randomDayIndex]
f, _ := os.Open(randomDayPath)
scanner := bufio.NewScanner(f)
if strings.HasSuffix(randomDayPath, ".gz") {
gz, _ := gzip.NewReader(f)
scanner = bufio.NewScanner(gz)
}
for scanner.Scan() {
line := scanner.Text()
lines = append(lines, line)
}
f.Close()
if len(lines) < 1 {
log.Infof("path %s", randomDayPath)
return "", errors.New("no lines found")
}
randomLineNumber := rand.Intn(len(lines))
return lines[randomLineNumber], nil
}

View File

@ -1,377 +0,0 @@
package filelog
import (
"bufio"
"compress/gzip"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"os"
"sort"
"strconv"
"strings"
"github.com/gempir/go-twitch-irc/v3"
log "github.com/sirupsen/logrus"
)
type Logger interface {
LogPrivateMessageForUser(user twitch.User, message twitch.PrivateMessage) error
LogClearchatMessageForUser(userID string, message twitch.ClearChatMessage) error
LogUserNoticeMessageForUser(userID string, message twitch.UserNoticeMessage) error
GetLastLogYearAndMonthForUser(channelID, userID string) (int, int, error)
GetAvailableLogsForUser(channelID, userID string) ([]UserLogFile, error)
ReadLogForUser(channelID, userID string, year string, month string) ([]string, error)
ReadRandomMessageForUser(channelID, userID string) (string, error)
LogPrivateMessageForChannel(message twitch.PrivateMessage) error
LogClearchatMessageForChannel(message twitch.ClearChatMessage) error
LogUserNoticeMessageForChannel(message twitch.UserNoticeMessage) error
ReadLogForChannel(channelID string, year int, month int, day int) ([]string, error)
ReadRandomMessageForChannel(channelID string) (string, error)
GetAvailableLogsForChannel(channelID string) ([]ChannelLogFile, error)
}
type FileLogger struct {
logPath string
}
func NewFileLogger(logPath string) FileLogger {
return FileLogger{
logPath: logPath,
}
}
func (l *FileLogger) LogPrivateMessageForUser(user twitch.User, message twitch.PrivateMessage) error {
year := message.Time.Year()
month := int(message.Time.Month())
err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/", message.RoomID, year, month), 0750)
if err != nil {
return err
}
filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%s.txt", message.RoomID, year, month, user.ID)
file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(message.Raw + "\n"); err != nil {
return err
}
return nil
}
func (l *FileLogger) LogClearchatMessageForUser(userID string, message twitch.ClearChatMessage) error {
year := message.Time.Year()
month := int(message.Time.Month())
err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/", message.RoomID, year, month), 0750)
if err != nil {
return err
}
filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%s.txt", message.RoomID, year, month, userID)
file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(message.Raw + "\n"); err != nil {
return err
}
return nil
}
func (l *FileLogger) LogUserNoticeMessageForUser(userID string, message twitch.UserNoticeMessage) error {
year := message.Time.Year()
month := int(message.Time.Month())
err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/", message.RoomID, year, month), 0750)
if err != nil {
return err
}
filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%s.txt", message.RoomID, year, month, userID)
file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(message.Raw + "\n"); err != nil {
return err
}
return nil
}
type UserLogFile struct {
path string
Year string `json:"year"`
Month string `json:"month"`
}
func (l *FileLogger) GetLastLogYearAndMonthForUser(channelID, userID string) (int, int, error) {
if channelID == "" || userID == "" {
return 0, 0, fmt.Errorf("Invalid channelID: %s or userID: %s", channelID, userID)
}
logFiles := []UserLogFile{}
years, _ := ioutil.ReadDir(l.logPath + "/" + channelID)
for _, yearDir := range years {
year := yearDir.Name()
months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/")
for _, monthDir := range months {
month := monthDir.Name()
path := fmt.Sprintf("%s/%s/%s/%s/%s.txt", l.logPath, channelID, year, month, userID)
if _, err := os.Stat(path); err == nil {
logFile := UserLogFile{path, year, month}
logFiles = append(logFiles, logFile)
} else if _, err := os.Stat(path + ".gz"); err == nil {
logFile := UserLogFile{path + ".gz", year, month}
logFiles = append(logFiles, logFile)
}
}
}
sort.Slice(logFiles, func(i, j int) bool {
yearA, _ := strconv.Atoi(logFiles[i].Year)
yearB, _ := strconv.Atoi(logFiles[j].Year)
monthA, _ := strconv.Atoi(logFiles[i].Month)
monthB, _ := strconv.Atoi(logFiles[j].Month)
if yearA == yearB {
return monthA > monthB
}
return yearA > yearB
})
if len(logFiles) > 0 {
year, _ := strconv.Atoi(logFiles[0].Year)
month, _ := strconv.Atoi(logFiles[0].Month)
return year, month, nil
}
return 0, 0, errors.New("No logs file")
}
func (l *FileLogger) GetAvailableLogsForUser(channelID, userID string) ([]UserLogFile, error) {
if channelID == "" || userID == "" {
return []UserLogFile{}, fmt.Errorf("Invalid channelID: %s or userID: %s", channelID, userID)
}
logFiles := []UserLogFile{}
years, _ := ioutil.ReadDir(l.logPath + "/" + channelID)
for _, yearDir := range years {
year := yearDir.Name()
months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/")
for _, monthDir := range months {
month := monthDir.Name()
path := fmt.Sprintf("%s/%s/%s/%s/%s.txt", l.logPath, channelID, year, month, userID)
if _, err := os.Stat(path); err == nil {
logFile := UserLogFile{path, year, month}
logFiles = append(logFiles, logFile)
} else if _, err := os.Stat(path + ".gz"); err == nil {
logFile := UserLogFile{path + ".gz", year, month}
logFiles = append(logFiles, logFile)
}
}
}
sort.Slice(logFiles, func(i, j int) bool {
yearA, _ := strconv.Atoi(logFiles[i].Year)
yearB, _ := strconv.Atoi(logFiles[j].Year)
monthA, _ := strconv.Atoi(logFiles[i].Month)
monthB, _ := strconv.Atoi(logFiles[j].Month)
if yearA == yearB {
return monthA > monthB
}
return yearA > yearB
})
if len(logFiles) > 0 {
return logFiles, nil
}
return logFiles, errors.New("No logs file")
}
type ChannelLogFile struct {
path string
Year string `json:"year"`
Month string `json:"month"`
Day string `json:"day"`
}
func (l *FileLogger) GetAvailableLogsForChannel(channelID string) ([]ChannelLogFile, error) {
if channelID == "" {
return []ChannelLogFile{}, fmt.Errorf("Invalid channelID: %s", channelID)
}
logFiles := []ChannelLogFile{}
years, _ := ioutil.ReadDir(l.logPath + "/" + channelID)
for _, yearDir := range years {
year := yearDir.Name()
months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/")
for _, monthDir := range months {
month := monthDir.Name()
days, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/" + month + "/")
for _, dayDir := range days {
day := dayDir.Name()
path := fmt.Sprintf("%s/%s/%s/%s/%s/channel.txt", l.logPath, channelID, year, month, day)
if _, err := os.Stat(path); err == nil {
logFile := ChannelLogFile{path, year, month, day}
logFiles = append(logFiles, logFile)
} else if _, err := os.Stat(path + ".gz"); err == nil {
logFile := ChannelLogFile{path + ".gz", year, month, day}
logFiles = append(logFiles, logFile)
}
}
}
}
sort.Slice(logFiles, func(i, j int) bool {
yearA, _ := strconv.Atoi(logFiles[i].Year)
yearB, _ := strconv.Atoi(logFiles[j].Year)
monthA, _ := strconv.Atoi(logFiles[i].Month)
monthB, _ := strconv.Atoi(logFiles[j].Month)
dayA, _ := strconv.Atoi(logFiles[j].Day)
dayB, _ := strconv.Atoi(logFiles[j].Day)
if yearA == yearB {
if monthA == monthB {
return dayA > dayB
}
return monthA > monthB
}
if monthA == monthB {
return dayA > dayB
}
return yearA > yearB
})
if len(logFiles) > 0 {
return logFiles, nil
}
return logFiles, errors.New("No logs file")
}
// ReadLogForUser fetch logs
func (l *FileLogger) ReadLogForUser(channelID, userID string, year string, month string) ([]string, error) {
if channelID == "" || userID == "" {
return []string{}, fmt.Errorf("Invalid channelID: %s or userID: %s", channelID, userID)
}
filename := fmt.Sprintf(l.logPath+"/%s/%s/%s/%s.txt", channelID, year, month, userID)
if _, err := os.Stat(filename); err != nil {
filename = filename + ".gz"
}
log.Debug("Opening " + filename)
f, err := os.Open(filename)
if err != nil {
return []string{}, errors.New("file not found: " + filename)
}
defer f.Close()
var reader io.Reader
if strings.HasSuffix(filename, ".gz") {
gz, err := gzip.NewReader(f)
if err != nil {
log.Error(err)
return []string{}, errors.New("file gzip not readable")
}
reader = gz
} else {
reader = f
}
scanner := bufio.NewScanner(reader)
if err != nil {
log.Error(err)
return []string{}, errors.New("file not readable")
}
content := []string{}
for scanner.Scan() {
line := scanner.Text()
content = append(content, line)
}
return content, nil
}
func (l *FileLogger) ReadRandomMessageForUser(channelID, userID string) (string, error) {
var userLogs []string
var lines []string
if channelID == "" || userID == "" {
return "", errors.New("missing channelID or userID")
}
years, _ := ioutil.ReadDir(l.logPath + "/" + channelID)
for _, yearDir := range years {
year := yearDir.Name()
months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/")
for _, monthDir := range months {
month := monthDir.Name()
path := fmt.Sprintf("%s/%s/%s/%s/%s.txt", l.logPath, channelID, year, month, userID)
if _, err := os.Stat(path); err == nil {
userLogs = append(userLogs, path)
} else if _, err := os.Stat(path + ".gz"); err == nil {
userLogs = append(userLogs, path+".gz")
}
}
}
if len(userLogs) < 1 {
return "", errors.New("no log found")
}
for _, logFile := range userLogs {
f, _ := os.Open(logFile)
scanner := bufio.NewScanner(f)
if strings.HasSuffix(logFile, ".gz") {
gz, _ := gzip.NewReader(f)
scanner = bufio.NewScanner(gz)
}
for scanner.Scan() {
line := scanner.Text()
lines = append(lines, line)
}
f.Close()
}
ranNum := rand.Intn(len(lines))
return lines[ranNum], nil
}

11
go.mod
View File

@ -1,11 +0,0 @@
module github.com/gempir/justlog
go 1.16
require (
github.com/gempir/go-twitch-irc/v3 v3.0.0
github.com/nicklaw5/helix v1.25.0
github.com/nursik/go-expire-map v1.1.0 // indirect
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0 // indirect
)

26
go.sum
View File

@ -1,26 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gempir/go-twitch-irc/v3 v3.0.0 h1:e34R+9BdKy+qrO/wN+FCt+BUtyn38gCnJuKWscIKbl4=
github.com/gempir/go-twitch-irc/v3 v3.0.0/go.mod h1:/W9KZIiyizVecp4PEb7kc4AlIyXKiCmvlXrzlpPUytU=
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/nicklaw5/helix v1.25.0 h1:Mrz537izZVsGdM3I46uGAAlslj61frgkhS/9xQqyT/M=
github.com/nicklaw5/helix v1.25.0/go.mod h1:yvXZFapT6afIoxnAvlWiJiUMsYnoHl7tNs+t0bloAMw=
github.com/nursik/go-expire-map v1.1.0 h1:C+OJ81JtHDSPJXfuu0g3e8RRjHLd5of8dVzyfDOB9KY=
github.com/nursik/go-expire-map v1.1.0/go.mod h1:wdQsai5n32Uw1IuVXXZoopePGCFh5vb0Dka/TRcboHs=
github.com/nursik/go-ordered-set v0.0.0-20190626022851-0e8872c36517 h1:jau4pavdQo5lHeVTjZEGrm4+zvVGZj8SFQt4awsLLXE=
github.com/nursik/go-ordered-set v0.0.0-20190626022851-0e8872c36517/go.mod h1:qFI7Mmmx8i+Qz8a52FarvpgPQzylvD3w77JAwvnFtKg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,208 +0,0 @@
package helix
import (
"net/http"
"strings"
"sync"
"time"
helixClient "github.com/nicklaw5/helix"
log "github.com/sirupsen/logrus"
)
// Client wrapper for helix
type Client struct {
clientID string
clientSecret string
appAccessToken string
client *helixClient.Client
httpClient *http.Client
}
var (
userCacheByID sync.Map
userCacheByUsername sync.Map
)
type TwitchApiClient interface {
GetUsersByUserIds([]string) (map[string]UserData, error)
GetUsersByUsernames([]string) (map[string]UserData, error)
}
// NewClient Create helix client
func NewClient(clientID string, clientSecret string) Client {
client, err := helixClient.NewClient(&helixClient.Options{
ClientID: clientID,
ClientSecret: clientSecret,
})
if err != nil {
panic(err)
}
resp, err := client.RequestAppAccessToken([]string{})
if err != nil {
panic(err)
}
log.Infof("Requested access token, response: %d, expires in: %d", resp.StatusCode, resp.Data.ExpiresIn)
client.SetAppAccessToken(resp.Data.AccessToken)
return Client{
clientID: clientID,
clientSecret: clientSecret,
appAccessToken: resp.Data.AccessToken,
client: client,
httpClient: &http.Client{},
}
}
// UserData exported data from twitch
type UserData struct {
ID string `json:"id"`
Login string `json:"login"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
BroadcasterType string `json:"broadcaster_type"`
Description string `json:"description"`
ProfileImageURL string `json:"profile_image_url"`
OfflineImageURL string `json:"offline_image_url"`
ViewCount int `json:"view_count"`
Email string `json:"email"`
}
// StartRefreshTokenRoutine refresh our token
func (c *Client) StartRefreshTokenRoutine() {
ticker := time.NewTicker(24 * time.Hour)
for range ticker.C {
resp, err := c.client.RequestAppAccessToken([]string{})
if err != nil {
log.Error(err)
continue
}
log.Infof("Requested access token from routine, response: %d, expires in: %d", resp.StatusCode, resp.Data.ExpiresIn)
c.client.SetAppAccessToken(resp.Data.AccessToken)
}
}
func chunkBy(items []string, chunkSize int) (chunks [][]string) {
for chunkSize < len(items) {
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
}
return append(chunks, items)
}
// GetUsersByUserIds receive userData for given ids
func (c *Client) GetUsersByUserIds(userIDs []string) (map[string]UserData, error) {
var filteredUserIDs []string
for _, id := range userIDs {
if _, ok := userCacheByID.Load(id); !ok {
filteredUserIDs = append(filteredUserIDs, id)
}
}
if len(filteredUserIDs) > 0 {
chunks := chunkBy(filteredUserIDs, 100)
for _, chunk := range chunks {
resp, err := c.client.GetUsers(&helixClient.UsersParams{
IDs: chunk,
})
if err != nil {
return map[string]UserData{}, err
}
log.Infof("%d GetUsersByUserIds %v", resp.StatusCode, chunk)
for _, user := range resp.Data.Users {
data := &UserData{
ID: user.ID,
Login: user.Login,
DisplayName: user.Login,
Type: user.Type,
BroadcasterType: user.BroadcasterType,
Description: user.Description,
ProfileImageURL: user.ProfileImageURL,
OfflineImageURL: user.OfflineImageURL,
ViewCount: user.ViewCount,
Email: user.Email,
}
userCacheByID.Store(user.ID, data)
userCacheByUsername.Store(user.Login, data)
}
}
}
result := make(map[string]UserData)
for _, id := range userIDs {
value, ok := userCacheByID.Load(id)
if !ok {
log.Debugf("Could not find userId, channel might be banned: %s", id)
continue
}
result[id] = *(value.(*UserData))
}
return result, nil
}
// GetUsersByUsernames fetches userdata from helix
func (c *Client) GetUsersByUsernames(usernames []string) (map[string]UserData, error) {
var filteredUsernames []string
for _, username := range usernames {
username = strings.ToLower(username)
if _, ok := userCacheByUsername.Load(username); !ok {
filteredUsernames = append(filteredUsernames, username)
}
}
if len(filteredUsernames) > 0 {
chunks := chunkBy(filteredUsernames, 100)
for _, chunk := range chunks {
resp, err := c.client.GetUsers(&helixClient.UsersParams{
Logins: chunk,
})
if err != nil {
return map[string]UserData{}, err
}
log.Infof("%d GetUsersByUsernames %v", resp.StatusCode, chunk)
for _, user := range resp.Data.Users {
data := &UserData{
ID: user.ID,
Login: user.Login,
DisplayName: user.Login,
Type: user.Type,
BroadcasterType: user.BroadcasterType,
Description: user.Description,
ProfileImageURL: user.ProfileImageURL,
OfflineImageURL: user.OfflineImageURL,
ViewCount: user.ViewCount,
Email: user.Email,
}
userCacheByID.Store(user.ID, data)
userCacheByUsername.Store(user.Login, data)
}
}
}
result := make(map[string]UserData)
for _, username := range usernames {
username = strings.ToLower(username)
value, ok := userCacheByUsername.Load(username)
if !ok {
log.Debugf("Could not find username, channel might be banned: %s", username)
continue
}
result[username] = *(value.(*UserData))
}
return result, nil
}

View File

@ -1,107 +0,0 @@
package humanize
import (
"fmt"
"strings"
"time"
)
func TimeSince(a time.Time) string {
return formatDiff(diff(a, time.Now()))
}
func formatDiff(years, months, days, hours, mins, secs int) string {
since := ""
if years > 0 {
switch years {
case 1:
since += fmt.Sprintf("%d year ", years)
default:
since += fmt.Sprintf("%d years ", years)
}
}
if months > 0 {
switch months {
case 1:
since += fmt.Sprintf("%d month ", months)
default:
since += fmt.Sprintf("%d months ", months)
}
}
if days > 0 {
switch days {
case 1:
since += fmt.Sprintf("%d day ", days)
default:
since += fmt.Sprintf("%d days ", days)
}
}
if hours > 0 {
switch hours {
case 1:
since += fmt.Sprintf("%d hour ", hours)
default:
since += fmt.Sprintf("%d hours ", hours)
}
}
if mins > 0 && days == 0 && months == 0 && years == 0 {
switch mins {
case 1:
since += fmt.Sprintf("%d min ", mins)
default:
since += fmt.Sprintf("%d mins ", mins)
}
}
if secs > 0 && days == 0 && months == 0 && years == 0 && hours == 0 {
switch secs {
case 1:
since += fmt.Sprintf("%d sec ", secs)
default:
since += fmt.Sprintf("%d secs ", secs)
}
}
return strings.TrimSpace(since)
}
func diff(a, b time.Time) (year, month, day, hour, min, sec int) {
if a.After(b) {
a, b = b, a
}
y1, M1, d1 := a.Date()
y2, M2, d2 := b.Date()
h1, m1, s1 := a.Clock()
h2, m2, s2 := b.Clock()
year = int(y2 - y1)
month = int(M2 - M1)
day = int(d2 - d1)
hour = int(h2 - h1)
min = int(m2 - m1)
sec = int(s2 - s1)
// Normalize negative values
if sec < 0 {
sec += 60
min--
}
if min < 0 {
min += 60
hour--
}
if hour < 0 {
hour += 24
day--
}
if day < 0 {
// days in month:
t := time.Date(y1, M1, 32, 0, 0, 0, 0, time.UTC)
day += 32 - t.Day()
month--
}
if month < 0 {
month += 12
year--
}
return
}

View File

@ -1,24 +0,0 @@
package humanize
import (
"testing"
"time"
)
var timeSinceTests = []struct {
in time.Time
out string
}{
{time.Now().AddDate(0, 0, -1), "1 day"},
{time.Now().AddDate(0, -1, -1), "1 month 1 day"},
{time.Now().Add(time.Minute * -10), "10 mins"},
{time.Now().Add(time.Minute * -10 + time.Second * -30), "10 mins 30 secs"},
}
func TestTimeSince(t *testing.T) {
for _, testCase := range timeSinceTests {
if since := TimeSince(testCase.in); since != testCase.out {
t.Errorf("Incorrect time since string. Expected %s, Actual: %s", testCase.out, since)
}
}
}

42
main.go
View File

@ -1,42 +0,0 @@
package main
import (
"embed"
"flag"
"github.com/gempir/justlog/api"
"github.com/gempir/justlog/archiver"
"github.com/gempir/justlog/bot"
"github.com/gempir/justlog/config"
"github.com/gempir/justlog/filelog"
"github.com/gempir/justlog/helix"
)
// content holds our static web server content.
//
//go:embed web/dist/*
var assets embed.FS
func main() {
configFile := flag.String("config", "config.json", "json config file")
flag.Parse()
cfg := config.NewConfig(*configFile)
fileLogger := filelog.NewFileLogger(cfg.LogsDirectory)
helixClient := helix.NewClient(cfg.ClientID, cfg.ClientSecret)
go helixClient.StartRefreshTokenRoutine()
if cfg.Archive {
archiver := archiver.NewArchiver(cfg.LogsDirectory)
go archiver.Boot()
}
bot := bot.NewBot(cfg, &helixClient, &fileLogger)
apiServer := api.NewServer(cfg, bot, &fileLogger, &helixClient, assets)
go apiServer.Init()
bot.Connect()
}

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

25
web/.gitignore vendored
View File

@ -1,25 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
dist/*
!dist/.gitkeep
public/swagger.json
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*