├── web ├── dist │ └── .gitkeep ├── src │ ├── vite-env.d.ts │ ├── errors │ │ └── OptOutError.ts │ ├── services │ │ └── isUserId.ts │ ├── types │ │ ├── ThirdPartyEmote.ts │ │ ├── Bttv.ts │ │ ├── log.ts │ │ ├── 7tv.ts │ │ └── Ffz.ts │ ├── components │ │ ├── OptOutMessage.tsx │ │ ├── User.tsx │ │ ├── Page.tsx │ │ ├── TwitchChatLogContainer.tsx │ │ ├── Settings.tsx │ │ ├── LogContainer.tsx │ │ ├── Docs.tsx │ │ ├── LogLine.tsx │ │ ├── Log.tsx │ │ ├── ContentLog.tsx │ │ ├── TwitchChatLogLine.tsx │ │ ├── Filters.tsx │ │ ├── Optout.tsx │ │ └── Message.tsx │ ├── icons │ │ └── Txt.tsx │ ├── hooks │ │ ├── useThirdPartyEmotes.ts │ │ ├── useChannels.ts │ │ ├── useBttvGlobalEmotes.ts │ │ ├── useFfzGlobalEmotes.ts │ │ ├── useFfzChannelEmotes.ts │ │ ├── use7tvGlobalEmotes.ts │ │ ├── useBttvChannelEmotes.ts │ │ ├── use7tvChannelEmotes.ts │ │ ├── useLocalStorage.ts │ │ ├── useAvailableLogs.ts │ │ └── useLog.ts │ ├── index.tsx │ └── store.tsx ├── .env.development ├── public │ ├── robots.txt │ └── favicon.ico ├── vite.config.ts ├── .gitignore ├── tsconfig.json ├── package.json └── index.html ├── .gitignore ├── go.mod ├── config.json.dist ├── Makefile ├── Dockerfile ├── archiver ├── archiver.go ├── gzip.go └── scanner.go ├── humanize ├── time_test.go └── time.go ├── .github └── workflows │ ├── ci.yml │ └── docker-publish.yml ├── api ├── optout.go ├── admin.go ├── channel.go ├── docs.go ├── logrequest.go ├── user.go └── server.go ├── main.go ├── LICENSE ├── go.sum ├── README.MD ├── bot ├── commands.go └── main.go ├── config └── main.go ├── filelog ├── channellog.go └── userlog.go └── helix └── user.go /web/dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=http://localhost:8025 2 | -------------------------------------------------------------------------------- /web/src/errors/OptOutError.ts: -------------------------------------------------------------------------------- 1 | export class OptOutError extends Error { } -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # Disallow web spiders everywhere 2 | User-agent: * 3 | Disallow: / -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gempir/justlog/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | justlog 2 | vendor/ 3 | .idea/ 4 | .vscode/ 5 | coverage-all.out 6 | coverage.out 7 | .env 8 | logs/ 9 | config.json -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); -------------------------------------------------------------------------------- /web/src/services/isUserId.ts: -------------------------------------------------------------------------------- 1 | export function isUserId(value: string) { 2 | return value.startsWith("id:"); 3 | } 4 | 5 | export function getUserId(value: string) { 6 | return value.replace("id:", ""); 7 | } -------------------------------------------------------------------------------- /web/src/types/ThirdPartyEmote.ts: -------------------------------------------------------------------------------- 1 | export interface ThirdPartyEmote { 2 | code: string, 3 | id: string, 4 | urls: EmoteUrls, 5 | } 6 | 7 | export interface EmoteUrls { 8 | small: string, 9 | medium: string, 10 | big: string, 11 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gempir/justlog 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/gempir/go-twitch-irc/v3 v3.0.0 7 | github.com/nicklaw5/helix v1.25.0 8 | github.com/nursik/go-expire-map v1.2.0 9 | github.com/sirupsen/logrus v1.8.1 10 | github.com/stretchr/testify v1.7.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /config.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "admins": ["gempir"], 3 | "logsDirectory": "./logs", 4 | "adminAPIKey": "noshot", 5 | "username": "gempbot", 6 | "oauth": "oauthtokenforchat", 7 | "botVerified": true, 8 | "clientID": "mytwitchclientid", 9 | "clientSecret": "mysecret", 10 | "logLevel": "info", 11 | "channels": ["77829817", "11148817"], 12 | "archive": true 13 | } 14 | -------------------------------------------------------------------------------- /web/src/components/OptOutMessage.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import React from "react"; 3 | 4 | const OptOutContainer = styled.div` 5 | display: block; 6 | font-weight: bold; 7 | color: var(--danger); 8 | font-size: 2rem; 9 | text-align: center; 10 | padding: 2rem; 11 | `; 12 | 13 | export function OptOutMessage() { 14 | return User or channel has opted out 15 | } -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | dist/* 6 | !dist/.gitkeep 7 | 8 | public/swagger.json 9 | 10 | /.pnp 11 | .pnp.js 12 | 13 | # testing 14 | /coverage 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /web/src/components/User.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | 5 | 6 | const UserContainer = styled.div.attrs(props => ({ 7 | style: { 8 | color: props.color, 9 | } 10 | }))` 11 | display: inline; 12 | `; 13 | 14 | export function User({ displayName, color }: { displayName: string, color: string }): JSX.Element { 15 | 16 | const renderColor = color !== "" ? color : "grey"; 17 | 18 | return 19 | {displayName}: 20 | ; 21 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | full: docs web build 2 | 3 | build: 4 | go build 5 | 6 | run: build 7 | ./justlog 8 | 9 | run_web: 10 | cd web && yarn start 11 | 12 | web: init_web 13 | cd web && yarn build 14 | 15 | init_web: 16 | cd web && yarn install --ignore-optional 17 | 18 | container: 19 | docker build -t gempir/justlog . 20 | 21 | run_container: 22 | docker run -p 8025:8025 --restart=unless-stopped -v $(PWD)/config.json:/etc/justlog.json -v $(PWD)/logs:/logs gempir/justlog:latest 23 | 24 | docs: 25 | swagger generate spec -m -o ./web/public/swagger.json -w api 26 | 27 | -------------------------------------------------------------------------------- /web/src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import styled from "styled-components"; 3 | import { store } from "../store"; 4 | import { Filters } from "./Filters"; 5 | import { LogContainer } from "./LogContainer"; 6 | import { OptoutPanel } from "./Optout"; 7 | 8 | const PageContainer = styled.div` 9 | 10 | `; 11 | 12 | export function Page() { 13 | const {state} = useContext(store); 14 | 15 | return 16 | 17 | {state.showOptout && } 18 | 19 | ; 20 | } -------------------------------------------------------------------------------- /web/src/types/Bttv.ts: -------------------------------------------------------------------------------- 1 | export interface BttvChannelEmotesResponse { 2 | // id: string; 3 | // bots: string[]; 4 | channelEmotes: Emote[]; 5 | sharedEmotes: Emote[]; 6 | } 7 | 8 | export type BttvGlobalEmotesResponse = Emote[]; 9 | 10 | export interface Emote { 11 | id: string; 12 | code: string; 13 | // imageType: ImageType; 14 | // userId?: string; 15 | // user?: User; 16 | } 17 | 18 | export enum ImageType { 19 | GIF = "gif", 20 | PNG = "png", 21 | } 22 | 23 | export interface User { 24 | id: string; 25 | name: string; 26 | displayName: string; 27 | providerId: string; 28 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/goswagger/swagger:latest as build-docs 2 | WORKDIR /app 3 | COPY . . 4 | RUN make docs 5 | 6 | FROM node:18-alpine as build-web 7 | WORKDIR /web 8 | COPY web . 9 | COPY --from=build-docs /app/web/public/swagger.json /web/public 10 | RUN yarn install --ignore-optional 11 | RUN yarn build 12 | 13 | FROM golang:alpine as build-app 14 | WORKDIR /app 15 | COPY . . 16 | COPY --from=build-web /web/dist /app/web/dist 17 | RUN go build -o app . 18 | 19 | FROM alpine:latest 20 | RUN apk --no-cache add ca-certificates 21 | COPY --from=build-app /app/app . 22 | USER 1000:1000 23 | CMD ["./app", "--config=/etc/justlog.json"] 24 | EXPOSE 8025 -------------------------------------------------------------------------------- /archiver/archiver.go: -------------------------------------------------------------------------------- 1 | package archiver 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func NewArchiver(logPath string) *Archiver { 8 | return &Archiver{ 9 | logPath: logPath, 10 | workQueue: make(chan string), 11 | } 12 | } 13 | 14 | type Archiver struct { 15 | logPath string 16 | workQueue chan string 17 | } 18 | 19 | func (a *Archiver) Boot() { 20 | go a.startScanner() 21 | a.startConsumer() 22 | } 23 | 24 | func (a *Archiver) startConsumer() { 25 | for task := range a.workQueue { 26 | a.gzipFile(task) 27 | } 28 | } 29 | 30 | func (a *Archiver) startScanner() { 31 | for { 32 | a.scanLogPath() 33 | time.Sleep(time.Second * 60) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /humanize/time_test.go: -------------------------------------------------------------------------------- 1 | package humanize 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | var timeSinceTests = []struct { 9 | in time.Time 10 | out string 11 | }{ 12 | {time.Now().AddDate(0, 0, -1), "1 day"}, 13 | {time.Now().AddDate(0, -1, -1), "1 month 1 day"}, 14 | {time.Now().Add(time.Minute * -10), "10 mins"}, 15 | {time.Now().Add(time.Minute * -10 + time.Second * -30), "10 mins 30 secs"}, 16 | } 17 | 18 | func TestTimeSince(t *testing.T) { 19 | for _, testCase := range timeSinceTests { 20 | if since := TimeSince(testCase.in); since != testCase.out { 21 | t.Errorf("Incorrect time since string. Expected %s, Actual: %s", testCase.out, since) 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /web/src/types/log.ts: -------------------------------------------------------------------------------- 1 | export interface UserLogResponse { 2 | messages: RawLogMessage[], 3 | } 4 | 5 | export interface LogMessage extends Omit { 6 | timestamp: Date, 7 | emotes: Array, 8 | } 9 | 10 | export interface RawLogMessage { 11 | text: string, 12 | systemText: string, 13 | username: string, 14 | displayName: string, 15 | channel: string, 16 | timestamp: string, 17 | type: number, 18 | raw: string, 19 | id: string, 20 | tags: Record, 21 | } 22 | 23 | export interface Emote { 24 | startIndex: number, 25 | endIndex: number, 26 | code: string, 27 | id: string, 28 | } -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "types": ["vite/client"], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /web/src/components/TwitchChatLogContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import styled from "styled-components"; 3 | import { useLog } from "../hooks/useLog"; 4 | import { store } from "../store"; 5 | import { TwitchChatLogLine } from "./TwitchChatLogLine"; 6 | 7 | const ContentLogContainer = styled.ul` 8 | list-style: none; 9 | padding: 0; 10 | margin: 0; 11 | width: 340px; 12 | `; 13 | 14 | export function TwitchChatContentLog({ year, month }: { year: string, month: string }) { 15 | const { state } = useContext(store); 16 | 17 | const logs = useLog(state.currentChannel ?? "", state.currentUsername ?? "", year, month) 18 | 19 | return 20 | {logs.map((log, index) => )} 21 | 22 | } -------------------------------------------------------------------------------- /web/src/icons/Txt.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function Txt() { 4 | return ( 5 | 6 | 7 | 12 | 13 | ); 14 | } -------------------------------------------------------------------------------- /web/src/types/7tv.ts: -------------------------------------------------------------------------------- 1 | export type StvGlobalEmotesResponse = StvGlobal 2 | export type StvChannelEmotesResponse = StvChannel 3 | 4 | interface StvGlobal { 5 | emotes: StvEmote[]; 6 | } 7 | 8 | interface StvChannel { 9 | emote_set: StvEmoteSet | null; 10 | } 11 | 12 | interface StvEmoteSet { 13 | id: string; 14 | name: string; 15 | emotes: StvEmote[]; 16 | } 17 | 18 | 19 | interface StvEmote { 20 | id: string; 21 | name: string; 22 | data: StvEmoteData; 23 | } 24 | 25 | interface StvEmoteData { 26 | id: string; 27 | name: string; 28 | listed: boolean; 29 | animated: boolean; 30 | host: StvEmoteHost; 31 | } 32 | 33 | interface StvEmoteHost { 34 | url: string; 35 | files: StvEmoteFile[]; 36 | } 37 | 38 | interface StvEmoteFile { 39 | name: string; 40 | width: number; 41 | height: number; 42 | format: string; 43 | } -------------------------------------------------------------------------------- /web/src/hooks/useThirdPartyEmotes.ts: -------------------------------------------------------------------------------- 1 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 2 | import { use7tvChannelEmotes } from "./use7tvChannelEmotes"; 3 | import { use7tvGlobalEmotes } from "./use7tvGlobalEmotes"; 4 | import { useBttvChannelEmotes } from "./useBttvChannelEmotes"; 5 | import { useBttvGlobalEmotes } from "./useBttvGlobalEmotes"; 6 | import { useFfzChannelEmotes } from "./useFfzChannelEmotes"; 7 | import { useFfzGlobalEmotes } from "./useFfzGlobalEmotes"; 8 | 9 | export function useThirdPartyEmotes(channelId: string): Array { 10 | const thirdPartyEmotes: Array = [ 11 | ...useBttvChannelEmotes(channelId), 12 | ...useFfzChannelEmotes(channelId), 13 | ...use7tvChannelEmotes(channelId), 14 | ...useBttvGlobalEmotes(), 15 | ...useFfzGlobalEmotes(), 16 | ...use7tvGlobalEmotes(), 17 | ]; 18 | 19 | return thirdPartyEmotes; 20 | } -------------------------------------------------------------------------------- /web/src/hooks/useChannels.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { useQuery } from "react-query"; 3 | import { store } from "../store"; 4 | 5 | export interface Channel { 6 | userID: string, 7 | name: string 8 | } 9 | 10 | export function useChannels(): Array { 11 | const { state } = useContext(store); 12 | 13 | const { data } = useQuery>(`channels`, () => { 14 | 15 | const queryUrl = new URL(`${state.apiBaseUrl}/channels`); 16 | 17 | return fetch(queryUrl.toString()).then((response) => { 18 | if (response.ok) { 19 | return response; 20 | } 21 | 22 | throw Error(response.statusText); 23 | }).then(response => response.json()) 24 | .then((data: { channels: Array }) => data.channels); 25 | }, { refetchOnWindowFocus: false, refetchOnReconnect: false }); 26 | 27 | return data ?? []; 28 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | 10 | jobs: 11 | 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | - name: Set up Go 1.x 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: ^1.13 21 | id: go 22 | 23 | - name: Check out code into the Go module directory 24 | uses: actions/checkout@v2 25 | 26 | - name: Get dependencies 27 | run: | 28 | go get -v -t -d ./... 29 | 30 | - name: Build 31 | run: go build -v . 32 | 33 | - name: Test 34 | run: go test -v . 35 | 36 | release: 37 | if: ${{ github.ref == 'refs/heads/main' }} 38 | runs-on: ubuntu-latest 39 | needs: build 40 | steps: 41 | 42 | - name: Set outputs 43 | id: vars 44 | run: echo "::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-4)" -------------------------------------------------------------------------------- /api/optout.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "math/rand" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | func init() { 10 | rand.Seed(time.Now().UnixNano()) 11 | } 12 | 13 | // swagger:route POST /optout justlog 14 | // 15 | // Generates optout code to use in chat 16 | // 17 | // Produces: 18 | // - application/json 19 | // 20 | // Schemes: https 21 | // 22 | // Responses: 23 | // 200: string 24 | func (s *Server) writeOptOutCode(w http.ResponseWriter, r *http.Request) { 25 | 26 | code := randomString(6) 27 | 28 | s.bot.OptoutCodes.Store(code, true) 29 | go func() { 30 | time.Sleep(time.Second * 60) 31 | s.bot.OptoutCodes.Delete(code) 32 | }() 33 | 34 | writeJSON(code, http.StatusOK, w, r) 35 | } 36 | 37 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890") 38 | 39 | func randomString(n int) string { 40 | b := make([]rune, n) 41 | for i := range b { 42 | b[i] = letterRunes[rand.Intn(len(letterRunes))] 43 | } 44 | return string(b) 45 | } 46 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { useContext } from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { QueryClientProvider } from 'react-query'; 5 | import { Page } from './components/Page'; 6 | import { StateProvider, store } from './store'; 7 | import { createTheme } from '@mui/material'; 8 | import { ThemeProvider } from '@mui/material/styles'; 9 | 10 | const pageTheme = createTheme({ 11 | palette: { 12 | mode: 'dark' 13 | }, 14 | }); 15 | 16 | function App() { 17 | const { state } = useContext(store); 18 | 19 | return 20 | 21 | 22 | } 23 | 24 | const container = document.getElementById('root') as Element; 25 | const root = createRoot(container); 26 | 27 | root.render( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | 7 | "github.com/gempir/justlog/api" 8 | "github.com/gempir/justlog/archiver" 9 | "github.com/gempir/justlog/bot" 10 | "github.com/gempir/justlog/config" 11 | "github.com/gempir/justlog/filelog" 12 | "github.com/gempir/justlog/helix" 13 | ) 14 | 15 | // content holds our static web server content. 16 | // 17 | //go:embed web/dist/* 18 | var assets embed.FS 19 | 20 | func main() { 21 | 22 | configFile := flag.String("config", "config.json", "json config file") 23 | flag.Parse() 24 | 25 | cfg := config.NewConfig(*configFile) 26 | 27 | fileLogger := filelog.NewFileLogger(cfg.LogsDirectory) 28 | helixClient := helix.NewClient(cfg.ClientID, cfg.ClientSecret) 29 | go helixClient.StartRefreshTokenRoutine() 30 | 31 | if cfg.Archive { 32 | archiver := archiver.NewArchiver(cfg.LogsDirectory) 33 | go archiver.Boot() 34 | } 35 | 36 | bot := bot.NewBot(cfg, &helixClient, &fileLogger) 37 | 38 | apiServer := api.NewServer(cfg, bot, &fileLogger, &helixClient, assets) 39 | go apiServer.Init() 40 | 41 | bot.Connect() 42 | } 43 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.10.6", 7 | "@emotion/styled": "^11.10.6", 8 | "@mui/icons-material": "^5.11.11", 9 | "@mui/material": "^5.11.12", 10 | "dayjs": "^1.11.7", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-linkify": "^1.0.0-alpha", 14 | "react-query": "^3.39.3", 15 | "react-window": "^1.8.8", 16 | "runes": "^0.4.3", 17 | "swagger-ui-react": "^4.17.1", 18 | "typescript": "^4.9.5" 19 | }, 20 | "scripts": { 21 | "start": "vite", 22 | "build": "vite build" 23 | }, 24 | "devDependencies": { 25 | "@mui/types": "^7.2.3", 26 | "@types/react": "^18.0.28", 27 | "@types/react-dom": "^18.0.11", 28 | "@types/react-linkify": "^1.0.1", 29 | "@types/react-window": "^1.8.5", 30 | "@types/runes": "^0.4.1", 31 | "@types/styled-components": "^5.1.26", 32 | "@types/swagger-ui-react": "^4.11.0", 33 | "@vitejs/plugin-react": "^3.1.0", 34 | "styled-components": "^5.3.8", 35 | "vite": "^4.1.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 gempir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/src/hooks/useBttvGlobalEmotes.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { QueryDefaults } from "../store"; 3 | import { BttvGlobalEmotesResponse } from "../types/Bttv"; 4 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 5 | 6 | export function useBttvGlobalEmotes(): Array { 7 | const { isLoading, error, data } = useQuery("bttv:global", () => { 8 | return fetch("https://api.betterttv.net/3/cached/emotes/global").then(res => 9 | res.json() as Promise 10 | ); 11 | }, QueryDefaults); 12 | 13 | if (isLoading || !data) { 14 | return []; 15 | } 16 | 17 | if (error) { 18 | console.error(error); 19 | return []; 20 | } 21 | 22 | const emotes = []; 23 | 24 | for (const channelEmote of data) { 25 | emotes.push({ 26 | id: channelEmote.id, 27 | code: channelEmote.code, 28 | urls: { 29 | small: `https://cdn.betterttv.net/emote/${channelEmote.id}/1x`, 30 | medium: `https://cdn.betterttv.net/emote/${channelEmote.id}/2x`, 31 | big: `https://cdn.betterttv.net/emote/${channelEmote.id}/3x`, 32 | } 33 | }); 34 | } 35 | 36 | return emotes; 37 | } -------------------------------------------------------------------------------- /web/src/hooks/useFfzGlobalEmotes.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { QueryDefaults } from "../store"; 3 | import { EmoteSet, FfzGlobalEmotesResponse } from "../types/Ffz"; 4 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 5 | 6 | export function useFfzGlobalEmotes(): Array { 7 | const { isLoading, error, data } = useQuery("ffz:global", () => { 8 | return fetch("https://api.frankerfacez.com/v1/set/global").then(res => 9 | res.json() as Promise 10 | ); 11 | }, QueryDefaults); 12 | 13 | if (isLoading || !data?.sets) { 14 | return []; 15 | } 16 | 17 | if (error) { 18 | console.error(error); 19 | return []; 20 | } 21 | 22 | const emotes = []; 23 | 24 | for (const set of Object.values(data.sets) as Array) { 25 | for (const channelEmote of set.emoticons) { 26 | emotes.push({ 27 | id: String(channelEmote.id), 28 | code: channelEmote.name, 29 | urls: { 30 | small: channelEmote.urls["1"], 31 | medium: channelEmote.urls["2"], 32 | big: channelEmote.urls["4"], 33 | } 34 | }); 35 | } 36 | } 37 | 38 | return emotes; 39 | } -------------------------------------------------------------------------------- /archiver/gzip.go: -------------------------------------------------------------------------------- 1 | package archiver 2 | 3 | import ( 4 | "bufio" 5 | "compress/gzip" 6 | "io/ioutil" 7 | "os" 8 | 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func (a *Archiver) gzipFile(filePath string) { 13 | file, err := os.Open(filePath) 14 | if err != nil { 15 | log.Errorf("File not found: %s Error: %s", filePath, err.Error()) 16 | return 17 | } 18 | defer file.Close() 19 | 20 | log.Infof("Archiving: %s", filePath) 21 | 22 | reader := bufio.NewReader(file) 23 | content, err := ioutil.ReadAll(reader) 24 | if err != nil { 25 | log.Errorf("Failure reading file: %s Error: %s", filePath, err.Error()) 26 | return 27 | } 28 | 29 | gzipFile, err := os.Create(filePath + ".gz") 30 | if err != nil { 31 | log.Errorf("Failure creating file: %s.gz Error: %s", filePath, err.Error()) 32 | return 33 | } 34 | defer gzipFile.Close() 35 | 36 | w := gzip.NewWriter(gzipFile) 37 | _, err = w.Write(content) 38 | if err != nil { 39 | log.Errorf("Failure writing content in file: %s.gz Error: %s", filePath, err.Error()) 40 | } 41 | w.Close() 42 | 43 | err = os.Remove(filePath) 44 | if err != nil { 45 | log.Errorf("Failure deleting file: %s Error: %s", filePath, err.Error()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/src/hooks/useFfzChannelEmotes.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { QueryDefaults } from "../store"; 3 | import { EmoteSet, FfzChannelEmotesResponse } from "../types/Ffz"; 4 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 5 | 6 | export function useFfzChannelEmotes(channelId: string): Array { 7 | const { isLoading, error, data } = useQuery(["ffz:channel", { channelId: channelId }], () => { 8 | if (channelId === "") { 9 | return Promise.resolve({sets: {}}); 10 | } 11 | 12 | return fetch(`https://api.frankerfacez.com/v1/room/id/${channelId}`).then(res => 13 | res.json() as Promise 14 | ); 15 | }, QueryDefaults); 16 | 17 | if (isLoading || !data?.sets) { 18 | return []; 19 | } 20 | 21 | if (error) { 22 | console.error(error); 23 | return []; 24 | } 25 | 26 | const emotes = []; 27 | 28 | for (const set of Object.values(data.sets) as Array) { 29 | for (const channelEmote of set.emoticons) { 30 | emotes.push({ 31 | id: String(channelEmote.id), 32 | code: channelEmote.name, 33 | urls: { 34 | small: channelEmote.urls["1"], 35 | medium: channelEmote.urls["2"], 36 | big: channelEmote.urls["4"], 37 | } 38 | }); 39 | } 40 | } 41 | 42 | return emotes; 43 | } -------------------------------------------------------------------------------- /web/src/hooks/use7tvGlobalEmotes.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { QueryDefaults } from "../store"; 3 | import { StvGlobalEmotesResponse } from "../types/7tv"; 4 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 5 | 6 | export function use7tvGlobalEmotes(): Array { 7 | const { isLoading, error, data } = useQuery("7tv:global", () => { 8 | return fetch("https://7tv.io/v3/emote-sets/global").then(res => { 9 | if (res.ok) { 10 | return res.json() as Promise; 11 | } 12 | 13 | return Promise.reject(res.statusText); 14 | }); 15 | }, QueryDefaults); 16 | 17 | if (isLoading || !data) { 18 | return []; 19 | } 20 | 21 | if (error) { 22 | console.error(error); 23 | return []; 24 | } 25 | 26 | const emotes = []; 27 | 28 | for (const channelEmote of data.emotes ?? []) { 29 | const webpEmotes = channelEmote.data.host.files.filter(i => i.format === 'WEBP'); 30 | const emoteURL = channelEmote.data.host.url; 31 | emotes.push({ 32 | id: channelEmote.id, 33 | code: channelEmote.name, 34 | urls: { 35 | small: `${emoteURL}/${webpEmotes[0].name}`, 36 | medium: `${emoteURL}/${webpEmotes[1].name}`, 37 | big: `${emoteURL}/${webpEmotes[2].name}`, 38 | } 39 | }); 40 | } 41 | 42 | return emotes; 43 | } -------------------------------------------------------------------------------- /web/src/hooks/useBttvChannelEmotes.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { QueryDefaults } from "../store"; 3 | import { BttvChannelEmotesResponse } from "../types/Bttv"; 4 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 5 | 6 | export function useBttvChannelEmotes(channelId: string): Array { 7 | const { isLoading, error, data } = useQuery(["bttv:channel", { channelId: channelId }], () => { 8 | if (channelId === "") { 9 | return Promise.resolve({ sharedEmotes: [], channelEmotes: [] }); 10 | } 11 | 12 | return fetch(`https://api.betterttv.net/3/cached/users/twitch/${channelId}`).then(res => 13 | res.json() as Promise 14 | ); 15 | }, QueryDefaults); 16 | 17 | if (isLoading) { 18 | return []; 19 | } 20 | 21 | if (error) { 22 | console.error(error); 23 | return []; 24 | } 25 | 26 | const emotes = []; 27 | 28 | for (const channelEmote of [...data?.channelEmotes ?? [], ...data?.sharedEmotes ?? []]) { 29 | emotes.push({ 30 | id: channelEmote.id, 31 | code: channelEmote.code, 32 | urls: { 33 | small: `https://cdn.betterttv.net/emote/${channelEmote.id}/1x`, 34 | medium: `https://cdn.betterttv.net/emote/${channelEmote.id}/2x`, 35 | big: `https://cdn.betterttv.net/emote/${channelEmote.id}/3x`, 36 | } 37 | }); 38 | } 39 | 40 | return emotes; 41 | } -------------------------------------------------------------------------------- /web/src/hooks/use7tvChannelEmotes.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { QueryDefaults } from "../store"; 3 | import { StvChannelEmotesResponse } from "../types/7tv"; 4 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 5 | 6 | export function use7tvChannelEmotes(channelId: string): Array { 7 | const { isLoading, error, data } = useQuery(["7tv:channel", { channelId: channelId }], () => { 8 | if (channelId === "") { 9 | return Promise.resolve({}); 10 | } 11 | 12 | return fetch(`https://7tv.io/v3/users/twitch/${channelId}`).then(res => { 13 | if (res.ok) { 14 | return res.json() as Promise; 15 | } 16 | 17 | return Promise.reject(res.statusText); 18 | }); 19 | }, QueryDefaults); 20 | 21 | if (isLoading) { 22 | return []; 23 | } 24 | 25 | if (error) { 26 | console.error(error); 27 | return []; 28 | } 29 | 30 | const emotes = []; 31 | 32 | for (const channelEmote of data?.emote_set?.emotes ?? []) { 33 | const webpEmotes = channelEmote.data.host.files.filter(i => i.format === 'WEBP'); 34 | const emoteURL = channelEmote.data.host.url; 35 | emotes.push({ 36 | id: channelEmote.id, 37 | code: channelEmote.name, 38 | urls: { 39 | small: `${emoteURL}/${webpEmotes[0]?.name}`, 40 | medium: `${emoteURL}/${webpEmotes[1]?.name}`, 41 | big: `${emoteURL}/${webpEmotes[2]?.name}`, 42 | } 43 | }); 44 | } 45 | 46 | return emotes; 47 | } 48 | -------------------------------------------------------------------------------- /web/src/types/Ffz.ts: -------------------------------------------------------------------------------- 1 | export interface FfzChannelEmotesResponse { 2 | // room: Room; 3 | sets: Sets; 4 | } 5 | 6 | export interface FfzGlobalEmotesResponse { 7 | sets: Sets; 8 | } 9 | 10 | // export interface Room { 11 | // _id: number; 12 | // css: null; 13 | // display_name: string; 14 | // id: string; 15 | // is_group: boolean; 16 | // mod_urls: null; 17 | // moderator_badge: null; 18 | // set: number; 19 | // twitch_id: number; 20 | // user_badges: UserBadges; 21 | // } 22 | 23 | // export interface UserBadges { 24 | // } 25 | 26 | export interface Sets { 27 | [key: string]: EmoteSet; 28 | } 29 | 30 | export interface EmoteSet { 31 | // _type: number; 32 | // css: null; 33 | // description: null; 34 | emoticons: Emoticon[]; 35 | // icon: null; 36 | // id: number; 37 | // title: string; 38 | } 39 | 40 | export interface Emoticon { 41 | // css: null; 42 | // height: number; 43 | // hidden: boolean; 44 | id: number; 45 | // margins: null; 46 | // modifier: boolean; 47 | name: string; 48 | // offset: null; 49 | // owner: Owner; 50 | // public: boolean; 51 | urls: { [key: string]: string }; 52 | // width: number; 53 | } 54 | 55 | export interface Owner { 56 | _id: number; 57 | display_name: string; 58 | name: string; 59 | } -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | justlog 10 | 11 | 12 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /web/src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export function useLocalStorage(key: string, initialValue: T): [T, (value: T) => void] { 4 | // State to store our value 5 | // Pass initial state function to useState so logic is only executed once 6 | const [storedValue, setStoredValue] = useState(() => { 7 | try { 8 | // Get from local storage by key 9 | const item = window.localStorage.getItem(key); 10 | // Parse stored json or if none return initialValue 11 | return item ? JSON.parse(item) : initialValue; 12 | } catch (error) { 13 | // If error also return initialValue 14 | console.log(error); 15 | setValue(initialValue); 16 | return initialValue; 17 | } 18 | }); 19 | 20 | // Return a wrapped version of useState's setter function that ... 21 | // ... persists the new value to localStorage. 22 | const setValue = (value: T): void => { 23 | try { 24 | // Allow value to be a function so we have same API as useState 25 | const valueToStore = 26 | value instanceof Function ? value(storedValue) : value; 27 | // Save state 28 | setStoredValue(valueToStore); 29 | // Save to local storage 30 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 31 | } catch (error) { 32 | // A more advanced implementation would handle the error case 33 | console.log(error); 34 | } 35 | }; 36 | 37 | let returnValue = storedValue; 38 | if (typeof initialValue === "object") { 39 | returnValue = { ...initialValue, ...storedValue }; 40 | } 41 | 42 | return [returnValue, setValue]; 43 | } -------------------------------------------------------------------------------- /web/src/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Menu, MenuItem } from "@mui/material"; 2 | import { Check, Clear, Settings as SettingsIcon } from "@mui/icons-material"; 3 | import React, { MouseEvent, useContext, useState } from "react"; 4 | import styled from "styled-components"; 5 | import { Setting, store } from "../store"; 6 | 7 | const SettingsContainer = styled.div` 8 | 9 | `; 10 | 11 | export function Settings() { 12 | const { state, setSettings } = useContext(store); 13 | const [anchorEl, setAnchorEl] = useState(null); 14 | 15 | const handleClick = (event: MouseEvent) => { 16 | setAnchorEl(event.currentTarget); 17 | }; 18 | 19 | const handleClose = () => { 20 | setAnchorEl(null); 21 | }; 22 | 23 | const toggleSetting = (name: string, setting: Setting) => { 24 | const newSetting = { ...setting, value: !setting.value }; 25 | 26 | setSettings({ ...state.settings, [name]: newSetting }); 27 | }; 28 | 29 | const menuItems = []; 30 | 31 | for (const [name, setting] of Object.entries(state.settings)) { 32 | menuItems.push( 33 | toggleSetting(name, setting)}> 34 | {setting.value ? : }  {setting.displayName} 35 | 36 | ); 37 | } 38 | 39 | return ( 40 | 41 | 42 | 43 | 44 | 51 | {menuItems} 52 | 53 | 54 | ); 55 | } -------------------------------------------------------------------------------- /web/src/components/LogContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from "react"; 2 | import styled from "styled-components"; 3 | import { OptOutError } from "../errors/OptOutError"; 4 | import { useAvailableLogs } from "../hooks/useAvailableLogs"; 5 | import { store } from "../store"; 6 | import { Log } from "./Log"; 7 | import { OptOutMessage } from "./OptOutMessage"; 8 | 9 | const LogContainerDiv = styled.div` 10 | color: white; 11 | padding: 2rem; 12 | padding-top: 0; 13 | width: 100%; 14 | `; 15 | 16 | export function LogContainer() { 17 | const { state } = useContext(store); 18 | 19 | const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; 20 | const ctrlKey = isMac ? "metaKey" : "ctrlKey"; 21 | 22 | useEffect(() => { 23 | const listener = function (e: KeyboardEvent) { 24 | if (e.key === 'f' && e[ctrlKey] && !state.settings.twitchChatMode.value) { 25 | e.preventDefault(); 26 | if (state.activeSearchField) { 27 | state.activeSearchField.focus(); 28 | } 29 | } 30 | }; 31 | 32 | window.addEventListener("keydown", listener) 33 | 34 | return () => window.removeEventListener("keydown", listener); 35 | }, [state.activeSearchField, state.settings.twitchChatMode.value, ctrlKey]); 36 | 37 | const [availableLogs, err] = useAvailableLogs(state.currentChannel, state.currentUsername); 38 | if (err instanceof OptOutError) { 39 | return ; 40 | } 41 | 42 | return 43 | {availableLogs.map((log, index) => )} 44 | 45 | } -------------------------------------------------------------------------------- /web/src/hooks/useAvailableLogs.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { useQuery } from "react-query"; 3 | import { OptOutError } from "../errors/OptOutError"; 4 | import { getUserId, isUserId } from "../services/isUserId"; 5 | import { store } from "../store"; 6 | 7 | export type AvailableLogs = Array<{ month: string, year: string }>; 8 | 9 | export function useAvailableLogs(channel: string | null, username: string | null): [AvailableLogs, Error | undefined] { 10 | const { state, setState } = useContext(store); 11 | 12 | // @ts-ignore I don't understand this error :) 13 | const { data } = useQuery<[AvailableLogs, Error | undefined]>(["availableLogs", { channel: channel, username: username }], () => { 14 | if (!channel || !username) { 15 | return Promise.resolve([[], undefined]); 16 | } 17 | 18 | const channelIsId = isUserId(channel); 19 | const usernameIsId = isUserId(username); 20 | 21 | if (channelIsId) { 22 | channel = getUserId(channel) 23 | } 24 | if (usernameIsId) { 25 | username = getUserId(username) 26 | } 27 | 28 | const queryUrl = new URL(`${state.apiBaseUrl}/list`); 29 | queryUrl.searchParams.append(`channel${channelIsId ? "id" : ""}`, channel); 30 | queryUrl.searchParams.append(`user${usernameIsId ? "id" : ""}`, username); 31 | 32 | return fetch(queryUrl.toString()).then((response) => { 33 | if (response.ok) { 34 | return response; 35 | } 36 | 37 | setState({ ...state, error: true }); 38 | 39 | if (response.status === 403) { 40 | throw new OptOutError(); 41 | } 42 | 43 | throw Error(response.statusText); 44 | }).then(response => response.json()) 45 | .then((data: { availableLogs: AvailableLogs }) => [data.availableLogs, undefined]) 46 | .catch((err) => { 47 | return [[], err]; 48 | }); 49 | }, { refetchOnWindowFocus: false, refetchOnReconnect: false }); 50 | 51 | return data as [AvailableLogs, Error | undefined] | undefined ?? [[], undefined]; 52 | } -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/gempir/go-twitch-irc/v3 v3.0.0 h1:e34R+9BdKy+qrO/wN+FCt+BUtyn38gCnJuKWscIKbl4= 5 | github.com/gempir/go-twitch-irc/v3 v3.0.0/go.mod h1:/W9KZIiyizVecp4PEb7kc4AlIyXKiCmvlXrzlpPUytU= 6 | github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= 7 | github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 8 | github.com/nicklaw5/helix v1.25.0 h1:Mrz537izZVsGdM3I46uGAAlslj61frgkhS/9xQqyT/M= 9 | github.com/nicklaw5/helix v1.25.0/go.mod h1:yvXZFapT6afIoxnAvlWiJiUMsYnoHl7tNs+t0bloAMw= 10 | github.com/nursik/go-expire-map v1.2.0 h1:3yl3sVLSnfw4vbo6OVZWP1tFND4CBm4hrYCrrAT5jkU= 11 | github.com/nursik/go-expire-map v1.2.0/go.mod h1:Mrqzxpk2G81At+TAlfImiNrKECj0gfVw7URv3p/eLzU= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 15 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 18 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 19 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 20 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= 21 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 24 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /web/src/components/Docs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import styled from "styled-components"; 3 | import DescriptionIcon from '@mui/icons-material/Description'; 4 | import { IconButton } from "@mui/material"; 5 | import SwaggerUI from "swagger-ui-react" 6 | import "swagger-ui-react/swagger-ui.css" 7 | import ReactDOM from "react-dom"; 8 | import { store } from "../store"; 9 | 10 | const DocsWrapper = styled.div` 11 | 12 | `; 13 | 14 | export function Docs() { 15 | const { state, setShowSwagger } = useContext(store); 16 | 17 | const handleClick = () => { 18 | setShowSwagger(!state.showSwagger); 19 | } 20 | 21 | return 22 | 23 | 24 | 25 | 26 | ; 27 | } 28 | 29 | const SwaggerWrapper = styled.div` 30 | position: absolute; 31 | top: 0; 32 | background: var(--bg); 33 | left: 0; 34 | right: 0; 35 | margin-top: 90px; 36 | z-index: 999; 37 | padding-bottom: 90px; 38 | 39 | .swagger-ui { 40 | background: var(--bg); 41 | 42 | .scheme-container { 43 | background: var(--bg-bright); 44 | } 45 | } 46 | `; 47 | 48 | interface SwaggerRequest { 49 | [k: string]: any; 50 | } 51 | 52 | function Swagger({ show }: { show: boolean }) { 53 | const { state } = useContext(store); 54 | const baseUrl = new URL(state.apiBaseUrl); 55 | 56 | const requestInterceptor = (req: SwaggerRequest): SwaggerRequest => { 57 | if (req.url.includes("swagger.json")) { 58 | return req; 59 | } 60 | 61 | const url = new URL(req.url); 62 | 63 | url.host = baseUrl.host; 64 | url.protocol = baseUrl.protocol; 65 | url.port = baseUrl.port; 66 | 67 | req.url = url.toString(); 68 | 69 | return req; 70 | } 71 | 72 | return ReactDOM.createPortal( 73 | 74 | 75 | , 76 | document.body 77 | ); 78 | } -------------------------------------------------------------------------------- /web/src/components/LogLine.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React, { useContext } from "react"; 3 | import styled from "styled-components"; 4 | import { useThirdPartyEmotes } from "../hooks/useThirdPartyEmotes"; 5 | import { store } from "../store"; 6 | import { LogMessage } from "../types/log"; 7 | import { Message } from "./Message"; 8 | import { User } from "./User"; 9 | import utc from "dayjs/plugin/utc"; 10 | import timezone from "dayjs/plugin/timezone"; 11 | 12 | dayjs.extend(utc) 13 | dayjs.extend(timezone) 14 | dayjs.tz.guess() 15 | 16 | const LogLineContainer = styled.li` 17 | display: flex; 18 | align-items: flex-start; 19 | margin-bottom: 1px; 20 | 21 | .timestamp { 22 | color: var(--text-dark); 23 | user-select: none; 24 | font-family: monospace; 25 | white-space: nowrap; 26 | line-height: 1.1rem; 27 | } 28 | 29 | .user { 30 | margin-left: 5px; 31 | user-select: none; 32 | font-weight: bold; 33 | line-height: 1.1rem; 34 | } 35 | 36 | .message { 37 | margin-left: 5px; 38 | line-height: 1.1rem; 39 | } 40 | `; 41 | 42 | export function LogLine({ message }: { message: LogMessage }) { 43 | const { state } = useContext(store); 44 | 45 | if (state.settings.showEmotes.value) { 46 | return ; 47 | } 48 | 49 | return 50 | {state.settings.showTimestamp.value &&{dayjs(message.timestamp).format("YYYY-MM-DD HH:mm:ss")}} 51 | {state.settings.showName.value && } 52 | 53 | 54 | } 55 | 56 | export function LogLineWithEmotes({ message }: { message: LogMessage }) { 57 | const thirdPartyEmotes = useThirdPartyEmotes(message.tags["room-id"]) 58 | const { state } = useContext(store); 59 | 60 | return 61 | {state.settings.showTimestamp.value &&{dayjs(message.timestamp).format("YYYY-MM-DD HH:mm:ss")}} 62 | {state.settings.showName.value && } 63 | 64 | 65 | } -------------------------------------------------------------------------------- /humanize/time.go: -------------------------------------------------------------------------------- 1 | package humanize 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | func TimeSince(a time.Time) string { 10 | return formatDiff(diff(a, time.Now())) 11 | } 12 | 13 | func formatDiff(years, months, days, hours, mins, secs int) string { 14 | since := "" 15 | if years > 0 { 16 | switch years { 17 | case 1: 18 | since += fmt.Sprintf("%d year ", years) 19 | default: 20 | since += fmt.Sprintf("%d years ", years) 21 | } 22 | } 23 | if months > 0 { 24 | switch months { 25 | case 1: 26 | since += fmt.Sprintf("%d month ", months) 27 | default: 28 | since += fmt.Sprintf("%d months ", months) 29 | } 30 | } 31 | if days > 0 { 32 | switch days { 33 | case 1: 34 | since += fmt.Sprintf("%d day ", days) 35 | default: 36 | since += fmt.Sprintf("%d days ", days) 37 | } 38 | } 39 | if hours > 0 { 40 | switch hours { 41 | case 1: 42 | since += fmt.Sprintf("%d hour ", hours) 43 | default: 44 | since += fmt.Sprintf("%d hours ", hours) 45 | } 46 | } 47 | if mins > 0 && days == 0 && months == 0 && years == 0 { 48 | switch mins { 49 | case 1: 50 | since += fmt.Sprintf("%d min ", mins) 51 | default: 52 | since += fmt.Sprintf("%d mins ", mins) 53 | } 54 | } 55 | if secs > 0 && days == 0 && months == 0 && years == 0 && hours == 0 { 56 | switch secs { 57 | case 1: 58 | since += fmt.Sprintf("%d sec ", secs) 59 | default: 60 | since += fmt.Sprintf("%d secs ", secs) 61 | } 62 | } 63 | return strings.TrimSpace(since) 64 | } 65 | 66 | func diff(a, b time.Time) (year, month, day, hour, min, sec int) { 67 | if a.After(b) { 68 | a, b = b, a 69 | } 70 | y1, M1, d1 := a.Date() 71 | y2, M2, d2 := b.Date() 72 | 73 | h1, m1, s1 := a.Clock() 74 | h2, m2, s2 := b.Clock() 75 | 76 | year = int(y2 - y1) 77 | month = int(M2 - M1) 78 | day = int(d2 - d1) 79 | hour = int(h2 - h1) 80 | min = int(m2 - m1) 81 | sec = int(s2 - s1) 82 | 83 | // Normalize negative values 84 | if sec < 0 { 85 | sec += 60 86 | min-- 87 | } 88 | if min < 0 { 89 | min += 60 90 | hour-- 91 | } 92 | if hour < 0 { 93 | hour += 24 94 | day-- 95 | } 96 | if day < 0 { 97 | // days in month: 98 | t := time.Date(y1, M1, 32, 0, 0, 0, 0, time.UTC) 99 | day += 32 - t.Day() 100 | month-- 101 | } 102 | if month < 0 { 103 | month += 12 104 | year-- 105 | } 106 | return 107 | } 108 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | # Publish `main` as Docker `latest` image. 6 | branches: 7 | - main 8 | 9 | # Publish `v1.2.3` tags as releases. 10 | tags: 11 | - v* 12 | 13 | # Run tests for any PRs. 14 | pull_request: 15 | 16 | env: 17 | # TODO: Change variable to your image's name. 18 | IMAGE_NAME: justlog 19 | 20 | jobs: 21 | # Run tests. 22 | # See also https://docs.docker.com/docker-hub/builds/automated-testing/ 23 | test: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: Run tests 30 | run: | 31 | if [ -f docker-compose.test.yml ]; then 32 | docker-compose --file docker-compose.test.yml build 33 | docker-compose --file docker-compose.test.yml run sut 34 | else 35 | docker build . --file Dockerfile 36 | fi 37 | 38 | # Push image to GitHub Packages. 39 | # See also https://docs.docker.com/docker-hub/builds/ 40 | push: 41 | # Ensure test job passes before pushing image. 42 | needs: test 43 | 44 | runs-on: ubuntu-latest 45 | if: github.event_name == 'push' 46 | 47 | steps: 48 | - uses: actions/checkout@v2 49 | 50 | - name: Build image 51 | run: docker build . --file Dockerfile --tag $IMAGE_NAME 52 | 53 | - name: Log into GitHub Container Registry 54 | # TODO: Create a PAT with `read:packages` and `write:packages` scopes and save it as an Actions secret `CR_PAT` 55 | run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin 56 | 57 | - name: Push image to GitHub Container Registry 58 | run: | 59 | IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME 60 | 61 | # Change all uppercase to lowercase 62 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 63 | 64 | # Strip git ref prefix from version 65 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 66 | 67 | # Strip "v" prefix from tag name 68 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 69 | 70 | # Use Docker `latest` tag convention 71 | [ "$VERSION" == "main" ] && VERSION=latest 72 | 73 | echo IMAGE_ID=$IMAGE_ID 74 | echo VERSION=$VERSION 75 | 76 | docker tag $IMAGE_NAME $IMAGE_ID:$VERSION 77 | docker push $IMAGE_ID:$VERSION 78 | -------------------------------------------------------------------------------- /web/src/components/Log.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@mui/material"; 2 | import React, { useContext, useState } from "react"; 3 | import styled from "styled-components"; 4 | import { Txt } from "../icons/Txt"; 5 | import { getUserId, isUserId } from "../services/isUserId"; 6 | import { store } from "../store"; 7 | import { ContentLog } from "./ContentLog"; 8 | import { TwitchChatContentLog } from "./TwitchChatLogContainer"; 9 | 10 | const LogContainer = styled.div` 11 | position: relative; 12 | background: var(--bg-bright); 13 | border-radius: 3px; 14 | padding: 0.5rem; 15 | margin-top: 3rem; 16 | 17 | .txt { 18 | position: absolute; 19 | top: 5px; 20 | right: 15px; 21 | opacity: 0.9; 22 | cursor: pointer; 23 | z-index: 999; 24 | 25 | &:hover { 26 | opacity: 1; 27 | } 28 | } 29 | `; 30 | 31 | export function Log({ year, month, initialLoad = false }: { year: string, month: string, initialLoad?: boolean }) { 32 | const { state } = useContext(store); 33 | const [load, setLoad] = useState(initialLoad); 34 | 35 | if (!load) { 36 | return 37 | setLoad(true)} /> 38 | 39 | } 40 | 41 | let txtHref = `${state.apiBaseUrl}` 42 | if (state.currentChannel && isUserId(state.currentChannel)) { 43 | txtHref += `/channelid/${getUserId(state.currentChannel)}` 44 | } else { 45 | txtHref += `/channel/${state.currentChannel}` 46 | } 47 | 48 | if (state.currentUsername && isUserId(state.currentUsername)) { 49 | txtHref += `/userid/${getUserId(state.currentUsername)}` 50 | } else { 51 | txtHref += `/user/${state.currentUsername}` 52 | } 53 | 54 | txtHref += `/${year}/${month}?reverse`; 55 | 56 | return 57 | 58 | {!state.settings.twitchChatMode.value && } 59 | {state.settings.twitchChatMode.value && } 60 | 61 | } 62 | 63 | const LoadableLogContainer = styled.div` 64 | 65 | `; 66 | 67 | function LoadableLog({ year, month, onLoad }: { year: string, month: string, onLoad: () => void }) { 68 | return 69 | 70 | 71 | } -------------------------------------------------------------------------------- /web/src/components/ContentLog.tsx: -------------------------------------------------------------------------------- 1 | import { InputAdornment, TextField } from "@mui/material"; 2 | import { Search } from "@mui/icons-material"; 3 | import React, { useContext, useState, CSSProperties, useRef, useEffect } from "react"; 4 | import styled from "styled-components"; 5 | import { useLog } from "../hooks/useLog"; 6 | import { store } from "../store"; 7 | import { LogLine } from "./LogLine"; 8 | import { FixedSizeList as List } from 'react-window'; 9 | 10 | const ContentLogContainer = styled.ul` 11 | padding: 0; 12 | margin: 0; 13 | position: relative; 14 | 15 | .search { 16 | position: absolute; 17 | top: -52px; 18 | width: 320px; 19 | left: 0; 20 | } 21 | 22 | .logLine { 23 | white-space: nowrap; 24 | } 25 | 26 | .list { 27 | scrollbar-color: dark; 28 | } 29 | `; 30 | 31 | export function ContentLog({ year, month }: { year: string, month: string }) { 32 | const { state, setState } = useContext(store); 33 | const [searchText, setSearchText] = useState(""); 34 | 35 | const logs = useLog(state.currentChannel ?? "", state.currentUsername ?? "", year, month) 36 | .filter(log => (log.text + log.systemText).toLowerCase().includes(searchText.toLowerCase())); 37 | 38 | const Row = ({ index, style }: { index: number, style: CSSProperties }) => ( 39 |
40 | ); 41 | 42 | const search = useRef(null); 43 | 44 | const handleMouseEnter = () => { 45 | setState({ ...state, activeSearchField: search.current }) 46 | } 47 | 48 | useEffect(() => { 49 | setState({ ...state, activeSearchField: search.current }) 50 | // eslint-disable-next-line react-hooks/exhaustive-deps 51 | }, []); 52 | 53 | return 54 | setSearchText(e.target.value)} 59 | size="small" 60 | InputProps={{ 61 | startAdornment: ( 62 | 63 | 64 | 65 | ), 66 | }} 67 | /> 68 | 75 | {Row} 76 | 77 | 78 | } -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # justlog [![Build Status](https://github.com/gempir/justlog/workflows/ci/badge.svg)](https://github.com/gempir/justlog/actions?query=workflow%3Aci) 2 | 3 | ### What is this? 4 | Justlog is a twitch irc bot. It focuses on logging and providing an api for the logs. 5 | 6 | ### Optout 7 | 8 | Click the X icon on the web ui to find an explanation for how to opt out. 9 | 10 | ### API 11 | 12 | API documentation can be viewed via the justlog frontend by clicking the "docs" symbol: 13 | ![image](https://user-images.githubusercontent.com/1629196/159481078-0de98f01-2816-49bd-8e17-ba7cf66cb064.png) 14 | 15 | ### Docker 16 | 17 | ``` 18 | mkdir logs 19 | docker run -p 8025:8025 --restart=unless-stopped -v $PWD/config.json:/etc/justlog.json -v $PWD/logs:/logs ghcr.io/gempir/justlog 20 | ``` 21 | 22 | ### Commands 23 | 24 | Only admins can use these commands 25 | 26 | - `!justlog status` will respond with uptime. 27 | - `!justlog join gempir pajlada` will join the channels and append them to the config. 28 | - `!justlog part gempir pajlada` will part the channels and remove them from the config. 29 | - `!justlog optout gempir gempbot` will opt out users of message logging or querying previous logs of that user. the same applies to a user's own channel. 30 | - `!justlog optin gempir gempbot` will revert the opt out. 31 | 32 | ### Config 33 | 34 | ``` 35 | { 36 | "admins": ["gempir"], // will only respond to commands executed by these users 37 | "logsDirectory": "./logs", // the directory to log into 38 | "adminAPIKey": "noshot", // your secret api key to access the admin api, can be any string, used in api request to admin endpoints 39 | "username": "gempbot", // bot username (can be justinfan123123 if you don't want to use an account) 40 | "oauth": "oauthtokenforchat", // bot token can be anything if justinfan123123 41 | "botVerified": true, // increase ratelimits if you have a verified bot, so the bot can join faster, false by default 42 | "clientID": "mytwitchclientid", // your client ID, needed for fetching userids or usernames etc 43 | "clientSecret": "mysecret", // your twitch client secret 44 | "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 45 | "channels": ["77829817", "11148817"], // the channels (userids) you want to log 46 | "archive": true // probably keep to true, will disable gzipping of old logs if false, useful if you setup compression on your own 47 | } 48 | ``` 49 | 50 | ### Development 51 | 52 | Development requires [yarn](https://classic.yarnpkg.com/) and [go-swagger](https://goswagger.io/) 53 | 54 | Run `go build && ./justlog` and `yarn start` in the web folder. 55 | 56 | Or run `make container` and `make run_container` 57 | -------------------------------------------------------------------------------- /web/src/components/TwitchChatLogLine.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React, { useContext } from "react"; 3 | import styled from "styled-components"; 4 | import { useThirdPartyEmotes } from "../hooks/useThirdPartyEmotes"; 5 | import { store } from "../store"; 6 | import { LogMessage } from "../types/log"; 7 | import { Message } from "./Message"; 8 | import { User } from "./User"; 9 | import utc from "dayjs/plugin/utc"; 10 | import timezone from "dayjs/plugin/timezone"; 11 | 12 | dayjs.extend(utc) 13 | dayjs.extend(timezone) 14 | dayjs.tz.guess() 15 | 16 | const TwitchChatLogLineContainer = styled.li` 17 | align-items: flex-start; 18 | margin-bottom: 1px; 19 | padding: 5px 20px; 20 | 21 | .timestamp { 22 | color: var(--text-dark); 23 | user-select: none; 24 | font-family: monospace; 25 | white-space: nowrap; 26 | margin-right: 5px; 27 | line-height: 1.1rem; 28 | } 29 | 30 | .user { 31 | display: inline-block; 32 | margin-right: 5px; 33 | user-select: none; 34 | font-weight: bold; 35 | line-height: 1.1rem; 36 | } 37 | 38 | .message { 39 | display: inline; 40 | line-height: 20px; 41 | 42 | a { 43 | word-wrap: break-word; 44 | } 45 | 46 | .emote { 47 | max-height: 28px; 48 | margin: 0 2px; 49 | margin-bottom: -10px; 50 | width: auto; 51 | } 52 | } 53 | `; 54 | 55 | export function TwitchChatLogLine({ message }: { message: LogMessage }) { 56 | const { state } = useContext(store); 57 | 58 | if (state.settings.showEmotes.value) { 59 | return ; 60 | } 61 | 62 | return 63 | {state.settings.showTimestamp.value && {dayjs(message.timestamp).format("YYYY-MM-DD HH:mm:ss")}} 64 | {state.settings.showName.value && } 65 | 66 | 67 | } 68 | 69 | function LogLineWithEmotes({ message }: { message: LogMessage }) { 70 | const thirdPartyEmotes = useThirdPartyEmotes(message.tags["room-id"]) 71 | const { state } = useContext(store); 72 | 73 | return 74 | {state.settings.showTimestamp.value && {dayjs(message.timestamp).format("YYYY-MM-DD HH:mm:ss")}} 75 | {state.settings.showName.value && } 76 | 77 | 78 | } -------------------------------------------------------------------------------- /web/src/components/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { Button, TextField } from "@mui/material"; 2 | import { Autocomplete } from '@mui/material'; 3 | import React, { FormEvent, useContext } from "react"; 4 | import { useQueryClient } from "react-query"; 5 | import styled from "styled-components"; 6 | import { useChannels } from "../hooks/useChannels"; 7 | import { store } from "../store"; 8 | import { Docs } from "./Docs"; 9 | import { Optout } from "./Optout"; 10 | import { Settings } from "./Settings"; 11 | 12 | const FiltersContainer = styled.form` 13 | display: inline-flex; 14 | align-items: center; 15 | padding: 15px; 16 | background: var(--bg-bright); 17 | border-bottom-left-radius: 3px; 18 | border-bottom-right-radius: 3px; 19 | margin: 0 auto; 20 | z-index: 99; 21 | 22 | > * { 23 | margin-right: 15px !important; 24 | 25 | &:last-child { 26 | margin-right: 0 !important; 27 | } 28 | } 29 | `; 30 | 31 | const FiltersWrapper = styled.div` 32 | text-align: center; 33 | `; 34 | 35 | export function Filters() { 36 | const { setCurrents, state } = useContext(store); 37 | const queryClient = useQueryClient(); 38 | const channels = useChannels(); 39 | 40 | const handleSubmit = (e: FormEvent) => { 41 | e.preventDefault(); 42 | 43 | if (e.target instanceof HTMLFormElement) { 44 | const data = new FormData(e.target); 45 | 46 | const channel = data.get("channel") as string | null; 47 | const username = data.get("username") as string | null; 48 | 49 | queryClient.invalidateQueries(["log", { channel: channel?.toLowerCase(), username: username?.toLowerCase() }]); 50 | 51 | setCurrents(channel, username); 52 | } 53 | }; 54 | 55 | return 56 | 57 | channel.name)} 60 | style={{ width: 225 }} 61 | defaultValue={state.currentChannel} 62 | getOptionLabel={(channel: string) => channel} 63 | clearOnBlur={false} 64 | renderInput={(params) => } 65 | /> 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | } -------------------------------------------------------------------------------- /web/src/components/Optout.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Button } from "@mui/material"; 2 | import { useContext, useState } from "react"; 3 | import styled from "styled-components"; 4 | import { store } from "../store"; 5 | import CancelIcon from '@mui/icons-material/Cancel'; 6 | 7 | const OptoutWrapper = styled.div` 8 | 9 | `; 10 | 11 | export function Optout() { 12 | const { state, setShowOptout } = useContext(store); 13 | 14 | const handleClick = () => { 15 | setShowOptout(!state.showOptout); 16 | } 17 | 18 | return 19 | 20 | 21 | 22 | ; 23 | } 24 | 25 | const OptoutPanelWrapper = styled.div` 26 | background: var(--bg-bright); 27 | color: var(--text); 28 | margin: 3rem; 29 | font-size: 1.5rem; 30 | padding: 2rem; 31 | 32 | code { 33 | background: var(--bg); 34 | padding: 1rem; 35 | border-radius: 3px; 36 | } 37 | 38 | .generator { 39 | margin-top: 2rem; 40 | display: flex; 41 | gap: 1rem; 42 | align-items: center; 43 | 44 | input { 45 | background: var(--bg); 46 | border: none; 47 | color: white; 48 | padding: 0.6rem; 49 | font-size: 1.5rem; 50 | text-align: center; 51 | border-radius: 3px; 52 | } 53 | } 54 | 55 | .small { 56 | font-size: 0.8rem; 57 | font-family: monospace; 58 | } 59 | `; 60 | 61 | export function OptoutPanel() { 62 | const { state } = useContext(store); 63 | const [code, setCode] = useState(""); 64 | 65 | const generateCode = () => { 66 | fetch(state.apiBaseUrl + "/optout", { method: "POST" }).then(res => res.json()).then(setCode).catch(console.error); 67 | }; 68 | 69 | return 70 |

71 | You can opt out from being logged. This will also disable access to your previously logged data.
72 | This applies to all chats of that justlog instance.
73 | Opting out is permanent, there is no reverse action. So think twice if you want to opt out. 74 |

75 |

76 | If you still want to optout generate a token here and paste the command into a logged chat.
77 | You will receive a confirmation message from the bot "@username, opted you out". 78 |

79 |
80 |
!justlog optout {""}
81 |
82 | 83 |
84 | {code &&

85 | This code is valid for 60 seconds 86 |

} 87 |
; 88 | } -------------------------------------------------------------------------------- /web/src/hooks/useLog.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { useQuery } from "react-query"; 3 | import { getUserId, isUserId } from "../services/isUserId"; 4 | import { store } from "../store"; 5 | import { Emote, LogMessage, UserLogResponse } from "../types/log"; 6 | import runes from "runes"; 7 | 8 | 9 | 10 | export function useLog(channel: string, username: string, year: string, month: string): Array { 11 | const { state } = useContext(store); 12 | 13 | const { data } = useQuery>(["log", { channel: channel, username: username, year: year, month: month }], () => { 14 | if (channel && username) { 15 | const channelIsId = isUserId(channel); 16 | const usernameIsId = isUserId(username); 17 | 18 | if (channelIsId) { 19 | channel = getUserId(channel) 20 | } 21 | if (usernameIsId) { 22 | username = getUserId(username) 23 | } 24 | 25 | const queryUrl = new URL(`${state.apiBaseUrl}/channel${channelIsId ? "id" : ""}/${channel}/user${usernameIsId ? "id" : ""}/${username}/${year}/${month}`); 26 | queryUrl.searchParams.append("json", "1"); 27 | if (!state.settings.newOnBottom.value) { 28 | queryUrl.searchParams.append("reverse", "1"); 29 | } 30 | 31 | return fetch(queryUrl.toString()).then((response) => { 32 | if (response.ok) { 33 | return response; 34 | } 35 | 36 | throw Error(response.statusText); 37 | }).then(response => response.json()).then((data: UserLogResponse) => { 38 | const messages: Array = []; 39 | 40 | for (const msg of data.messages) { 41 | messages.push({ ...msg, timestamp: new Date(msg.timestamp), emotes: parseEmotes(msg.text, msg.tags["emotes"]) }) 42 | } 43 | 44 | return messages; 45 | }); 46 | } 47 | 48 | return []; 49 | }, { refetchOnWindowFocus: false, refetchOnReconnect: false }); 50 | 51 | return data ?? []; 52 | } 53 | 54 | function parseEmotes(messageText: string, emotes: string | undefined): Array { 55 | const parsed: Array = []; 56 | if (!emotes) { 57 | return parsed; 58 | } 59 | 60 | const groups = emotes.split("/"); 61 | 62 | for (const group of groups) { 63 | const [id, positions] = group.split(":"); 64 | const positionGroups = positions.split(","); 65 | 66 | for (const positionGroup of positionGroups) { 67 | const [startPos, endPos] = positionGroup.split("-"); 68 | 69 | const startIndex = Number(startPos); 70 | const endIndex = Number(endPos) + 1; 71 | 72 | parsed.push({ 73 | id, 74 | startIndex: startIndex, 75 | endIndex: endIndex, 76 | code: runes.substr(messageText, startIndex, endIndex - startIndex + 1) 77 | }); 78 | } 79 | } 80 | 81 | return parsed; 82 | } -------------------------------------------------------------------------------- /archiver/scanner.go: -------------------------------------------------------------------------------- 1 | package archiver 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "io/ioutil" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func (a *Archiver) scanLogPath() { 13 | channelFiles, err := ioutil.ReadDir(a.logPath) 14 | if err != nil { 15 | log.Error(err) 16 | } 17 | 18 | for _, channelId := range channelFiles { 19 | if channelId.IsDir() { 20 | yearFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name()) 21 | if err != nil { 22 | log.Error(err) 23 | } 24 | 25 | yearFiles = a.filterFiles(yearFiles) 26 | 27 | for _, year := range yearFiles { 28 | if year.IsDir() { 29 | monthFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name() + "/" + year.Name()) 30 | if err != nil { 31 | log.Error(err) 32 | } 33 | 34 | monthFiles = a.filterFiles(monthFiles) 35 | 36 | for _, month := range monthFiles { 37 | if month.IsDir() { 38 | dayFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name()) 39 | if err != nil { 40 | log.Error(err) 41 | } 42 | 43 | dayFiles = a.filterFiles(dayFiles) 44 | 45 | for _, dayOrUserId := range dayFiles { 46 | if dayOrUserId.IsDir() { 47 | channelLogFiles, err := ioutil.ReadDir(a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name() + "/" + dayOrUserId.Name()) 48 | if err != nil { 49 | log.Error(err) 50 | } 51 | 52 | channelLogFiles = a.filterFiles(channelLogFiles) 53 | 54 | for _, channelLogFile := range channelLogFiles { 55 | if strings.HasSuffix(channelLogFile.Name(), ".txt") { 56 | dayInt, err := strconv.Atoi(dayOrUserId.Name()) 57 | if err != nil { 58 | log.Errorf("Failure converting day to int in scanner %s", err.Error()) 59 | continue 60 | } 61 | 62 | if dayInt == int(time.Now().Day()) { 63 | continue 64 | } 65 | 66 | a.workQueue <- a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name() + "/" + dayOrUserId.Name() + "/" + channelLogFile.Name() 67 | } 68 | } 69 | 70 | } else if strings.HasSuffix(dayOrUserId.Name(), ".txt") { 71 | monthInt, err := strconv.Atoi(month.Name()) 72 | if err != nil { 73 | log.Errorf("Failure converting month to int in scanner %s", err.Error()) 74 | continue 75 | } 76 | 77 | if monthInt == int(time.Now().Month()) { 78 | continue 79 | } 80 | 81 | a.workQueue <- a.logPath + "/" + channelId.Name() + "/" + year.Name() + "/" + month.Name() + "/" + dayOrUserId.Name() 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | } 89 | } 90 | } 91 | } 92 | 93 | func (a *Archiver) filterFiles(files []os.FileInfo) []os.FileInfo { 94 | var result []os.FileInfo 95 | 96 | for _, file := range files { 97 | if !strings.HasPrefix(file.Name(), ".") { 98 | result = append(result, file) 99 | } 100 | } 101 | 102 | return result 103 | } 104 | -------------------------------------------------------------------------------- /web/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import Linkify from "react-linkify"; 3 | import styled from "styled-components"; 4 | import { store } from "../store"; 5 | import { LogMessage } from "../types/log"; 6 | import { ThirdPartyEmote } from "../types/ThirdPartyEmote"; 7 | import runes from "runes"; 8 | 9 | const MessageContainer = styled.div` 10 | a { 11 | margin: 0 2px; 12 | color: var(--theme2); 13 | text-decoration: none; 14 | 15 | &:hover, &:active, &:focus { 16 | color: var(--theme2-bright); 17 | } 18 | } 19 | `; 20 | 21 | const SystemMessageWrapper = styled.span` 22 | font-style: italic; 23 | `; 24 | 25 | const Emote = styled.img` 26 | max-height: 18px; 27 | margin: 0 2px; 28 | margin-bottom: -2px; 29 | width: auto; 30 | `; 31 | 32 | export function Message({ message, thirdPartyEmotes }: { message: LogMessage, thirdPartyEmotes: Array }): JSX.Element { 33 | const { state } = useContext(store); 34 | const renderMessage = []; 35 | 36 | let replaced; 37 | let buffer = ""; 38 | 39 | let messageText = message.text; 40 | let renderMessagePrefix = ""; 41 | if (message.systemText) { 42 | renderMessagePrefix = `${message.systemText}`; 43 | } 44 | 45 | const messageTextEmoji = runes(messageText); 46 | 47 | for (let x = 0; x <= messageTextEmoji.length; x++) { 48 | const c = messageTextEmoji[x]; 49 | 50 | replaced = false; 51 | 52 | if (state.settings.showEmotes.value) { 53 | for (const emote of message.emotes) { 54 | if (emote.startIndex === x) { 55 | replaced = true; 56 | renderMessage.push(); 63 | x += emote.endIndex - emote.startIndex - 1; 64 | break; 65 | } 66 | } 67 | } 68 | 69 | if (!replaced) { 70 | if (c !== " " && x !== messageTextEmoji.length) { 71 | buffer += c; 72 | continue; 73 | } 74 | let emoteFound = false; 75 | 76 | for (const emote of thirdPartyEmotes) { 77 | if (buffer.trim() === emote.code) { 78 | renderMessage.push(); 85 | emoteFound = true; 86 | buffer = ""; 87 | 88 | break; 89 | } 90 | } 91 | 92 | if (!emoteFound) { 93 | renderMessage.push( ( 94 | 95 | {decoratedText} 96 | 97 | )}>{buffer}); 98 | buffer = ""; 99 | } 100 | 101 | if (c === " " && !state.settings.twitchChatMode.value) { 102 | renderMessage.push(<> ); 103 | } else { 104 | renderMessage.push(c); 105 | } 106 | } 107 | } 108 | 109 | const prefixElement = renderMessagePrefix ? 110 | {renderMessagePrefix} : null; 111 | 112 | return 113 | {prefixElement}{renderMessagePrefix && renderMessage ? ' ' : ''}{renderMessage} 114 | ; 115 | }; 116 | -------------------------------------------------------------------------------- /api/admin.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func (s *Server) authenticateAdmin(w http.ResponseWriter, r *http.Request) bool { 10 | apiKey := r.Header.Get("X-Api-Key") 11 | 12 | if apiKey == "" || apiKey != s.cfg.AdminAPIKey { 13 | http.Error(w, "No I don't think so.", http.StatusForbidden) 14 | return false 15 | } 16 | 17 | return true 18 | } 19 | 20 | type channelsDeleteRequest struct { 21 | // list of userIds 22 | Channels []string `json:"channels"` 23 | } 24 | 25 | type channelConfigsJoinRequest struct { 26 | // list of userIds 27 | Channels []string `json:"channels"` 28 | } 29 | 30 | // swagger:route POST /admin/channels admin addChannels 31 | // 32 | // Will add the channels to log, only works with userIds 33 | // 34 | // Consumes: 35 | // - application/json 36 | // 37 | // Produces: 38 | // - application/json 39 | // - text/plain 40 | // 41 | // Security: 42 | // - api_key: 43 | // 44 | // Schemes: https 45 | // 46 | // Responses: 47 | // 200: 48 | // 400: 49 | // 405: 50 | // 500: 51 | 52 | // swagger:route DELETE /admin/channels admin deleteChannels 53 | // 54 | // Will remove the channels to log, only works with userIds 55 | // 56 | // Consumes: 57 | // - application/json 58 | // 59 | // Produces: 60 | // - application/json 61 | // - text/plain 62 | // 63 | // Security: 64 | // - api_key: 65 | // 66 | // Schemes: https 67 | // 68 | // Responses: 69 | // 200: 70 | // 400: 71 | // 405: 72 | // 500: 73 | func (s *Server) writeChannels(w http.ResponseWriter, r *http.Request) { 74 | if r.Method != http.MethodPost && r.Method != http.MethodDelete { 75 | http.Error(w, "We'll see, we'll see. The winner gets tea.", http.StatusMethodNotAllowed) 76 | return 77 | } 78 | 79 | if r.Method == http.MethodDelete { 80 | var request channelsDeleteRequest 81 | 82 | err := json.NewDecoder(r.Body).Decode(&request) 83 | if err != nil { 84 | http.Error(w, "ANYWAYS: "+err.Error(), http.StatusBadRequest) 85 | return 86 | } 87 | 88 | s.cfg.RemoveChannels(request.Channels...) 89 | data, err := s.helixClient.GetUsersByUserIds(request.Channels) 90 | if err != nil { 91 | http.Error(w, "Failed to get channel names to leave, config might be already updated", http.StatusInternalServerError) 92 | return 93 | } 94 | for _, userData := range data { 95 | s.bot.Part(userData.Login) 96 | } 97 | 98 | writeJSON(fmt.Sprintf("Doubters? Removed channels %v", request.Channels), http.StatusOK, w, r) 99 | return 100 | } 101 | 102 | var request channelConfigsJoinRequest 103 | 104 | err := json.NewDecoder(r.Body).Decode(&request) 105 | if err != nil { 106 | http.Error(w, "ANYWAYS: "+err.Error(), http.StatusBadRequest) 107 | return 108 | } 109 | 110 | s.cfg.AddChannels(request.Channels...) 111 | data, err := s.helixClient.GetUsersByUserIds(request.Channels) 112 | if err != nil { 113 | http.Error(w, "Failed to get channel names to join, config might be already updated", http.StatusInternalServerError) 114 | return 115 | } 116 | for _, userData := range data { 117 | s.bot.Join(userData.Login) 118 | } 119 | 120 | writeJSON(fmt.Sprintf("Doubters? Joined channels or already in: %v", request.Channels), http.StatusOK, w, r) 121 | } 122 | -------------------------------------------------------------------------------- /api/channel.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | 7 | "github.com/gempir/go-twitch-irc/v3" 8 | ) 9 | 10 | // RandomQuoteJSON response when request a random message 11 | type RandomChannelQuoteJSON struct { 12 | Channel string `json:"channel"` 13 | Username string `json:"username"` 14 | DisplayName string `json:"displayName"` 15 | Message string `json:"message"` 16 | Timestamp timestamp `json:"timestamp"` 17 | } 18 | 19 | // swagger:route GET /channel/{channel}/random logs randomChannelLog 20 | // 21 | // Get a random line from the entire channel log's history 22 | // 23 | // Produces: 24 | // - application/json 25 | // - text/plain 26 | // 27 | // Responses: 28 | // 200: chatLog 29 | 30 | // swagger:route GET /channelid/{channelid}/random logs randomChannelLog 31 | // 32 | // Get a random line from the entire channel log's history 33 | // 34 | // Produces: 35 | // - application/json 36 | // - text/plain 37 | // 38 | // Responses: 39 | // 200: chatLog 40 | func (s *Server) getChannelRandomQuote(request logRequest) (*chatLog, error) { 41 | rawMessage, err := s.fileLogger.ReadRandomMessageForChannel(request.channelid) 42 | if err != nil { 43 | return &chatLog{}, err 44 | } 45 | parsedMessage := twitch.ParseMessage(rawMessage) 46 | 47 | chatMsg := createChatMessage(parsedMessage) 48 | 49 | return &chatLog{Messages: []chatMessage{chatMsg}}, nil 50 | } 51 | 52 | // swagger:route GET /channel/{channel} logs channelLogs 53 | // 54 | // Get entire channel logs of current day 55 | // 56 | // Produces: 57 | // - application/json 58 | // - text/plain 59 | // 60 | // Responses: 61 | // 200: chatLog 62 | 63 | // swagger:route GET /channel/{channel}/{year}/{month}/{day} logs channelLogsYearMonthDay 64 | // 65 | // Get entire channel logs of given day 66 | // 67 | // Produces: 68 | // - application/json 69 | // - text/plain 70 | // 71 | // Responses: 72 | // 200: chatLog 73 | func (s *Server) getChannelLogs(request logRequest) (*chatLog, error) { 74 | yearStr := request.time.year 75 | monthStr := request.time.month 76 | dayStr := request.time.day 77 | 78 | year, err := strconv.Atoi(yearStr) 79 | if err != nil { 80 | return &chatLog{}, errors.New("invalid year") 81 | } 82 | month, err := strconv.Atoi(monthStr) 83 | if err != nil { 84 | return &chatLog{}, errors.New("invalid month") 85 | } 86 | day, err := strconv.Atoi(dayStr) 87 | if err != nil { 88 | return &chatLog{}, errors.New("invalid day") 89 | } 90 | 91 | logMessages, err := s.fileLogger.ReadLogForChannel(request.channelid, year, month, day) 92 | if err != nil { 93 | return &chatLog{}, err 94 | } 95 | 96 | if request.reverse { 97 | reverseSlice(logMessages) 98 | } 99 | 100 | logResult := createLogResult() 101 | 102 | for _, rawMessage := range logMessages { 103 | logResult.Messages = append(logResult.Messages, createChatMessage(twitch.ParseMessage(rawMessage))) 104 | } 105 | 106 | return &logResult, nil 107 | } 108 | 109 | func (s *Server) getChannelLogsRange(request logRequest) (*chatLog, error) { 110 | fromTime, toTime, err := parseFromTo(request.time.from, request.time.to, channelHourLimit) 111 | if err != nil { 112 | return &chatLog{}, err 113 | } 114 | 115 | var logMessages []string 116 | 117 | logMessages, _ = s.fileLogger.ReadLogForChannel(request.channelid, fromTime.Year(), int(fromTime.Month()), fromTime.Day()) 118 | 119 | if fromTime.Month() != toTime.Month() { 120 | additionalMessages, _ := s.fileLogger.ReadLogForChannel(request.channelid, toTime.Year(), int(toTime.Month()), toTime.Day()) 121 | 122 | logMessages = append(logMessages, additionalMessages...) 123 | } 124 | 125 | if request.reverse { 126 | reverseSlice(logMessages) 127 | } 128 | 129 | logResult := createLogResult() 130 | 131 | for _, rawMessage := range logMessages { 132 | parsedMessage := twitch.ParseMessage(rawMessage) 133 | 134 | switch message := parsedMessage.(type) { 135 | case *twitch.PrivateMessage: 136 | if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() { 137 | continue 138 | } 139 | case *twitch.ClearChatMessage: 140 | if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() { 141 | continue 142 | } 143 | case *twitch.UserNoticeMessage: 144 | if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() { 145 | continue 146 | } 147 | } 148 | 149 | logResult.Messages = append(logResult.Messages, createChatMessage(parsedMessage)) 150 | } 151 | 152 | return &logResult, nil 153 | } 154 | -------------------------------------------------------------------------------- /bot/commands.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | twitch "github.com/gempir/go-twitch-irc/v3" 8 | "github.com/gempir/justlog/humanize" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | const ( 13 | commandPrefix = "!justlog" 14 | errNoUsernames = ", at least 1 username has to be provided. multiple usernames have to be separated with a space" 15 | errRequestingUserIDs = ", something went wrong requesting the userids" 16 | ) 17 | 18 | func (b *Bot) handlePrivateMessageCommands(message twitch.PrivateMessage) { 19 | if !strings.HasPrefix(strings.ToLower(message.Message), commandPrefix) { 20 | return 21 | } 22 | 23 | args := strings.Fields(message.Message[len(commandPrefix):]) 24 | if len(args) < 1 { 25 | return 26 | } 27 | commandName := args[0] 28 | args = args[1:] 29 | 30 | switch commandName { 31 | case "status": 32 | if !contains(b.cfg.Admins, message.User.Name) { 33 | return 34 | } 35 | uptime := humanize.TimeSince(b.startTime) 36 | b.Say(message.Channel, fmt.Sprintf("%s, uptime: %s", message.User.DisplayName, uptime)) 37 | 38 | case "join": 39 | if !contains(b.cfg.Admins, message.User.Name) { 40 | return 41 | } 42 | b.handleJoin(message, args) 43 | 44 | case "part": 45 | if !contains(b.cfg.Admins, message.User.Name) { 46 | return 47 | } 48 | b.handlePart(message, args) 49 | 50 | case "optout": 51 | b.handleOptOut(message, args) 52 | case "optin": 53 | if !contains(b.cfg.Admins, message.User.Name) { 54 | return 55 | } 56 | b.handleOptIn(message, args) 57 | } 58 | } 59 | 60 | // Commands 61 | 62 | func (b *Bot) handleJoin(message twitch.PrivateMessage, args []string) { 63 | if len(args) < 1 { 64 | b.Say(message.Channel, message.User.DisplayName+errNoUsernames) 65 | return 66 | } 67 | 68 | users, err := b.helixClient.GetUsersByUsernames(args) 69 | if err != nil { 70 | log.Error(err) 71 | b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs) 72 | } 73 | 74 | ids := []string{} 75 | for _, user := range users { 76 | ids = append(ids, user.ID) 77 | b.Join(user.Login) 78 | } 79 | b.cfg.AddChannels(ids...) 80 | b.Say(message.Channel, fmt.Sprintf("%s, added channels: %v", message.User.DisplayName, ids)) 81 | } 82 | 83 | func (b *Bot) handlePart(message twitch.PrivateMessage, args []string) { 84 | if len(args) < 1 { 85 | b.Say(message.Channel, message.User.DisplayName+errNoUsernames) 86 | return 87 | } 88 | 89 | users, err := b.helixClient.GetUsersByUsernames(args) 90 | if err != nil { 91 | log.Error(err) 92 | b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs) 93 | } 94 | 95 | ids := []string{} 96 | for _, user := range users { 97 | ids = append(ids, user.ID) 98 | b.Part(user.Login) 99 | } 100 | b.cfg.RemoveChannels(ids...) 101 | b.Say(message.Channel, fmt.Sprintf("%s, removed channels: %v", message.User.DisplayName, ids)) 102 | } 103 | 104 | func (b *Bot) handleOptOut(message twitch.PrivateMessage, args []string) { 105 | if len(args) < 1 { 106 | b.Say(message.Channel, message.User.DisplayName+errNoUsernames) 107 | return 108 | } 109 | 110 | if _, ok := b.OptoutCodes.LoadAndDelete(args[0]); ok { 111 | b.cfg.OptOutUsers(message.User.ID) 112 | b.Say(message.Channel, fmt.Sprintf("%s, opted you out", message.User.DisplayName)) 113 | return 114 | } 115 | 116 | if !contains(b.cfg.Admins, message.User.Name) { 117 | return 118 | } 119 | 120 | users, err := b.helixClient.GetUsersByUsernames(args) 121 | if err != nil { 122 | log.Error(err) 123 | b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs) 124 | } 125 | 126 | ids := []string{} 127 | for _, user := range users { 128 | ids = append(ids, user.ID) 129 | } 130 | b.cfg.OptOutUsers(ids...) 131 | b.Say(message.Channel, fmt.Sprintf("%s, opted out channels: %v", message.User.DisplayName, ids)) 132 | } 133 | 134 | func (b *Bot) handleOptIn(message twitch.PrivateMessage, args []string) { 135 | if len(args) < 1 { 136 | b.Say(message.Channel, message.User.DisplayName+errNoUsernames) 137 | return 138 | } 139 | 140 | users, err := b.helixClient.GetUsersByUsernames(args) 141 | if err != nil { 142 | log.Error(err) 143 | b.Say(message.Channel, message.User.DisplayName+errRequestingUserIDs) 144 | } 145 | 146 | ids := []string{} 147 | for _, user := range users { 148 | ids = append(ids, user.ID) 149 | } 150 | 151 | b.cfg.RemoveOptOut(ids...) 152 | b.Say(message.Channel, fmt.Sprintf("%s, opted in channels: %v", message.User.DisplayName, ids)) 153 | } 154 | 155 | // Utilities 156 | 157 | func contains(arr []string, str string) bool { 158 | for _, x := range arr { 159 | if x == str { 160 | return true 161 | } 162 | } 163 | return false 164 | } 165 | -------------------------------------------------------------------------------- /config/main.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // Config application configuration 13 | type Config struct { 14 | configFile string 15 | configFilePermissions os.FileMode 16 | BotVerified bool `json:"botVerified"` 17 | LogsDirectory string `json:"logsDirectory"` 18 | Archive bool `json:"archive"` 19 | AdminAPIKey string `json:"adminAPIKey"` 20 | Username string `json:"username"` 21 | OAuth string `json:"oauth"` 22 | ListenAddress string `json:"listenAddress"` 23 | Admins []string `json:"admins"` 24 | Channels []string `json:"channels"` 25 | ClientID string `json:"clientID"` 26 | ClientSecret string `json:"clientSecret"` 27 | LogLevel string `json:"logLevel"` 28 | OptOut map[string]bool `json:"optOut"` 29 | } 30 | 31 | // NewConfig create configuration from file 32 | func NewConfig(filePath string) *Config { 33 | cfg := loadConfiguration(filePath) 34 | 35 | log.Info("Loaded config from " + filePath) 36 | 37 | return cfg 38 | } 39 | 40 | // AddChannels adds channels to the config 41 | func (cfg *Config) AddChannels(channelIDs ...string) { 42 | for _, id := range channelIDs { 43 | cfg.Channels = appendIfMissing(cfg.Channels, id) 44 | } 45 | 46 | cfg.persistConfig() 47 | } 48 | 49 | // OptOutUsers will opt out a user 50 | func (cfg *Config) OptOutUsers(userIDs ...string) { 51 | for _, id := range userIDs { 52 | cfg.OptOut[id] = true 53 | } 54 | 55 | cfg.persistConfig() 56 | } 57 | 58 | // IsOptedOut check if a user is opted out 59 | func (cfg *Config) IsOptedOut(userID string) bool { 60 | _, ok := cfg.OptOut[userID] 61 | 62 | return ok 63 | } 64 | 65 | // AddChannels remove user from opt out 66 | func (cfg *Config) RemoveOptOut(userIDs ...string) { 67 | for _, id := range userIDs { 68 | delete(cfg.OptOut, id) 69 | } 70 | 71 | cfg.persistConfig() 72 | } 73 | 74 | // RemoveChannels removes channels from the config 75 | func (cfg *Config) RemoveChannels(channelIDs ...string) { 76 | channels := cfg.Channels 77 | 78 | for i, channel := range channels { 79 | for _, removeChannel := range channelIDs { 80 | if channel == removeChannel { 81 | channels[i] = channels[len(channels)-1] 82 | channels[len(channels)-1] = "" 83 | channels = channels[:len(channels)-1] 84 | } 85 | } 86 | } 87 | 88 | cfg.Channels = channels 89 | cfg.persistConfig() 90 | } 91 | 92 | func appendIfMissing(slice []string, i string) []string { 93 | for _, ele := range slice { 94 | if ele == i { 95 | return slice 96 | } 97 | } 98 | return append(slice, i) 99 | } 100 | 101 | func (cfg *Config) persistConfig() { 102 | fileContents, err := json.MarshalIndent(*cfg, "", " ") 103 | if err != nil { 104 | log.Error(err) 105 | return 106 | } 107 | 108 | err = ioutil.WriteFile(cfg.configFile, fileContents, cfg.configFilePermissions) 109 | if err != nil { 110 | log.Error(err) 111 | } 112 | } 113 | 114 | func loadConfiguration(filePath string) *Config { 115 | // setup defaults 116 | cfg := Config{ 117 | configFile: filePath, 118 | LogsDirectory: "./logs", 119 | ListenAddress: ":8025", 120 | Username: "justinfan777777", 121 | OAuth: "oauth:777777777", 122 | Channels: []string{}, 123 | Admins: []string{"gempir"}, 124 | LogLevel: "info", 125 | Archive: true, 126 | OptOut: map[string]bool{}, 127 | } 128 | 129 | info, err := os.Stat(filePath) 130 | if err != nil { 131 | log.Fatal(err) 132 | } 133 | cfg.configFilePermissions = info.Mode() 134 | 135 | configFile, err := os.Open(filePath) 136 | if err != nil { 137 | log.Fatal(err) 138 | } 139 | defer configFile.Close() 140 | 141 | jsonParser := json.NewDecoder(configFile) 142 | err = jsonParser.Decode(&cfg) 143 | if err != nil { 144 | log.Fatal(err) 145 | } 146 | 147 | // normalize 148 | cfg.LogsDirectory = strings.TrimSuffix(cfg.LogsDirectory, "/") 149 | cfg.OAuth = strings.TrimPrefix(cfg.OAuth, "oauth:") 150 | cfg.LogLevel = strings.ToLower(cfg.LogLevel) 151 | cfg.setupLogger() 152 | 153 | // ensure required 154 | if cfg.ClientID == "" { 155 | log.Fatal("No clientID specified") 156 | } 157 | 158 | return &cfg 159 | } 160 | 161 | func (cfg *Config) setupLogger() { 162 | switch cfg.LogLevel { 163 | case "fatal": 164 | log.SetLevel(log.FatalLevel) 165 | case "panic": 166 | log.SetLevel(log.PanicLevel) 167 | case "error": 168 | log.SetLevel(log.ErrorLevel) 169 | case "warn": 170 | log.SetLevel(log.WarnLevel) 171 | case "info": 172 | log.SetLevel(log.InfoLevel) 173 | case "debug": 174 | log.SetLevel(log.DebugLevel) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /web/src/store.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from "react"; 2 | import { QueryClient } from 'react-query'; 3 | import { useLocalStorage } from "./hooks/useLocalStorage"; 4 | 5 | export interface Settings { 6 | showEmotes: Setting, 7 | showName: Setting, 8 | showTimestamp: Setting, 9 | twitchChatMode: Setting, 10 | newOnBottom: Setting, 11 | } 12 | 13 | export enum LocalStorageSettings { 14 | showEmotes, 15 | showName, 16 | showTimestamp, 17 | twitchChatMode, 18 | newOnBottom, 19 | } 20 | 21 | export interface Setting { 22 | displayName: string, 23 | value: boolean, 24 | } 25 | 26 | export interface State { 27 | settings: Settings, 28 | queryClient: QueryClient, 29 | apiBaseUrl: string, 30 | currentChannel: string | null, 31 | currentUsername: string | null, 32 | error: boolean, 33 | activeSearchField: HTMLInputElement | null, 34 | showSwagger: boolean, 35 | showOptout: boolean, 36 | } 37 | 38 | export type Action = Record; 39 | 40 | const url = new URL(window.location.href); 41 | const defaultContext = { 42 | state: { 43 | queryClient: new QueryClient(), 44 | apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? window.location.protocol + "//" + window.location.host, 45 | settings: { 46 | showEmotes: { 47 | displayName: "Show Emotes", 48 | value: true, 49 | }, 50 | showName: { 51 | displayName: "Show Name", 52 | value: true, 53 | }, 54 | showTimestamp: { 55 | displayName: "Show Timestamp", 56 | value: true, 57 | }, 58 | twitchChatMode: { 59 | displayName: "Twitch Chat Mode", 60 | value: false, 61 | }, 62 | newOnBottom: { 63 | displayName: "Newest messages on bottom", 64 | value: false, 65 | }, 66 | }, 67 | currentChannel: url.searchParams.get("channel"), 68 | currentUsername: url.searchParams.get("username"), 69 | showSwagger: url.searchParams.has("swagger"), 70 | showOptout: url.searchParams.has("optout"), 71 | error: false, 72 | } as State, 73 | setState: (state: State) => { }, 74 | setCurrents: (currentChannel: string | null = null, currentUsername: string | null = null) => { }, 75 | setSettings: (newSettings: Settings) => { }, 76 | setShowSwagger: (show: boolean) => { }, 77 | setShowOptout: (show: boolean) => { }, 78 | }; 79 | 80 | const store = createContext(defaultContext); 81 | const { Provider } = store; 82 | 83 | const StateProvider = ({ children }: { children: JSX.Element }): JSX.Element => { 84 | 85 | const [settings, setSettingsStorage] = useLocalStorage("justlog:settings", defaultContext.state.settings); 86 | const [state, setState] = useState({ ...defaultContext.state, settings }); 87 | 88 | const setShowSwagger = (show: boolean) => { 89 | const url = new URL(window.location.href); 90 | 91 | if (show) { 92 | url.searchParams.set("swagger", "") 93 | url.searchParams.delete("optout"); 94 | } else { 95 | url.searchParams.delete("swagger"); 96 | } 97 | 98 | window.history.replaceState({}, "justlog", url.toString()); 99 | 100 | setState({ ...state, showSwagger: show, showOptout: false }) 101 | } 102 | 103 | const setShowOptout = (show: boolean) => { 104 | const url = new URL(window.location.href); 105 | 106 | if (show) { 107 | url.searchParams.set("optout", ""); 108 | url.searchParams.delete("swagger"); 109 | } else { 110 | url.searchParams.delete("optout"); 111 | } 112 | 113 | window.history.replaceState({}, "justlog", url.toString()); 114 | 115 | setState({ ...state, showOptout: show, showSwagger: false }) 116 | } 117 | 118 | const setSettings = (newSettings: Settings) => { 119 | for (const key of Object.keys(newSettings)) { 120 | if (typeof (defaultContext.state.settings as unknown as Record)[key] === "undefined") { 121 | delete (newSettings as unknown as Record)[key]; 122 | } 123 | } 124 | 125 | state.queryClient.removeQueries("log"); 126 | 127 | setSettingsStorage(newSettings); 128 | setState({ ...state, settings: newSettings }); 129 | } 130 | 131 | const setCurrents = (currentChannel: string | null = null, currentUsername: string | null = null) => { 132 | currentChannel = currentChannel?.toLowerCase().trim() ?? null; 133 | currentUsername = currentUsername?.toLowerCase().trim() ?? null; 134 | 135 | setState({ ...state, currentChannel, currentUsername, error: false }); 136 | 137 | const url = new URL(window.location.href); 138 | if (currentChannel) { 139 | url.searchParams.set("channel", currentChannel); 140 | } 141 | if (currentUsername) { 142 | url.searchParams.set("username", currentUsername); 143 | } 144 | 145 | window.history.replaceState({}, "justlog", url.toString()); 146 | } 147 | 148 | return {children}; 149 | }; 150 | 151 | export { store, StateProvider }; 152 | 153 | export const QueryDefaults = { 154 | staleTime: 5 * 10 * 1000, 155 | }; 156 | -------------------------------------------------------------------------------- /filelog/channellog.go: -------------------------------------------------------------------------------- 1 | package filelog 2 | 3 | import ( 4 | "bufio" 5 | "compress/gzip" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "math/rand" 11 | "os" 12 | "strings" 13 | 14 | "github.com/gempir/go-twitch-irc/v3" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | func (l *FileLogger) LogPrivateMessageForChannel(message twitch.PrivateMessage) error { 19 | year := message.Time.Year() 20 | month := int(message.Time.Month()) 21 | day := message.Time.Day() 22 | err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/%d", message.RoomID, year, month, day), 0750) 23 | if err != nil { 24 | return err 25 | } 26 | filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", message.RoomID, year, month, day) 27 | 28 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640) 29 | if err != nil { 30 | return err 31 | } 32 | defer file.Close() 33 | 34 | if _, err = file.WriteString(message.Raw + "\n"); err != nil { 35 | return err 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func (l *FileLogger) LogClearchatMessageForChannel(message twitch.ClearChatMessage) error { 42 | year := message.Time.Year() 43 | month := int(message.Time.Month()) 44 | day := message.Time.Day() 45 | err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/%d", message.RoomID, year, month, day), 0750) 46 | if err != nil { 47 | return err 48 | } 49 | filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", message.RoomID, year, month, day) 50 | 51 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640) 52 | if err != nil { 53 | return err 54 | } 55 | defer file.Close() 56 | 57 | if _, err = file.WriteString(message.Raw + "\n"); err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (l *FileLogger) LogUserNoticeMessageForChannel(message twitch.UserNoticeMessage) error { 65 | year := message.Time.Year() 66 | month := int(message.Time.Month()) 67 | day := message.Time.Day() 68 | err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/%d", message.RoomID, year, month, day), 0750) 69 | if err != nil { 70 | return err 71 | } 72 | filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", message.RoomID, year, month, day) 73 | 74 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640) 75 | if err != nil { 76 | return err 77 | } 78 | defer file.Close() 79 | 80 | if _, err = file.WriteString(message.Raw + "\n"); err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func (l *FileLogger) ReadLogForChannel(channelID string, year int, month int, day int) ([]string, error) { 88 | filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%d/channel.txt", channelID, year, month, day) 89 | 90 | if _, err := os.Stat(filename); err != nil { 91 | filename = filename + ".gz" 92 | } 93 | 94 | f, err := os.Open(filename) 95 | if err != nil { 96 | return []string{}, errors.New("file not found: " + filename) 97 | } 98 | defer f.Close() 99 | 100 | var reader io.Reader 101 | 102 | if strings.HasSuffix(filename, ".gz") { 103 | gz, err := gzip.NewReader(f) 104 | if err != nil { 105 | log.Error(err) 106 | return []string{}, errors.New("file gzip not readable") 107 | } 108 | reader = gz 109 | } else { 110 | reader = f 111 | } 112 | 113 | scanner := bufio.NewScanner(reader) 114 | if err != nil { 115 | log.Error(err) 116 | return []string{}, errors.New("file not readable") 117 | } 118 | 119 | content := []string{} 120 | 121 | for scanner.Scan() { 122 | line := scanner.Text() 123 | content = append(content, line) 124 | } 125 | 126 | return content, nil 127 | } 128 | 129 | func (l *FileLogger) ReadRandomMessageForChannel(channelID string) (string, error) { 130 | var dayFileList []string 131 | var lines []string 132 | 133 | if channelID == "" { 134 | return "", errors.New("missing channelID") 135 | } 136 | 137 | years, _ := ioutil.ReadDir(l.logPath + "/" + channelID) 138 | 139 | for _, yearDir := range years { 140 | year := yearDir.Name() 141 | months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/") 142 | for _, monthDir := range months { 143 | month := monthDir.Name() 144 | 145 | 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} 146 | 147 | for _, day := range possibleDays { 148 | dayDirPath := l.logPath + "/" + channelID + "/" + year + "/" + month + "/" + fmt.Sprint(day) 149 | logFiles, err := ioutil.ReadDir(dayDirPath) 150 | if err != nil { 151 | continue 152 | } 153 | 154 | for _, logFile := range logFiles { 155 | logFilePath := dayDirPath + "/" + logFile.Name() 156 | dayFileList = append(dayFileList, logFilePath) 157 | } 158 | } 159 | } 160 | } 161 | 162 | if len(dayFileList) < 1 { 163 | return "", errors.New("no log found") 164 | } 165 | 166 | randomDayIndex := rand.Intn(len(dayFileList)) 167 | randomDayPath := dayFileList[randomDayIndex] 168 | 169 | f, _ := os.Open(randomDayPath) 170 | scanner := bufio.NewScanner(f) 171 | 172 | if strings.HasSuffix(randomDayPath, ".gz") { 173 | gz, _ := gzip.NewReader(f) 174 | scanner = bufio.NewScanner(gz) 175 | } 176 | 177 | for scanner.Scan() { 178 | line := scanner.Text() 179 | lines = append(lines, line) 180 | } 181 | 182 | f.Close() 183 | 184 | if len(lines) < 1 { 185 | log.Infof("path %s", randomDayPath) 186 | return "", errors.New("no lines found") 187 | } 188 | 189 | randomLineNumber := rand.Intn(len(lines)) 190 | return lines[randomLineNumber], nil 191 | } 192 | -------------------------------------------------------------------------------- /api/docs.go: -------------------------------------------------------------------------------- 1 | // Package classification justlog API 2 | // 3 | // https://github.com/gempir/justlog 4 | // 5 | // Schemes: https 6 | // BasePath: / 7 | // 8 | // Consumes: 9 | // - application/json 10 | // - application/xml 11 | // 12 | // Produces: 13 | // - application/json 14 | // - text/plain 15 | // 16 | // SecurityDefinitions: 17 | // api_key: 18 | // type: apiKey 19 | // name: X-Api-Key 20 | // in: header 21 | // 22 | // swagger:meta 23 | package api 24 | 25 | type LogParams struct { 26 | // in: query 27 | Json string `json:"json"` 28 | // in: query 29 | Reverse string `json:"reverse"` 30 | // in: query 31 | From int32 `json:"from"` 32 | // in: query 33 | To int32 `json:"to"` 34 | } 35 | 36 | //swagger:parameters channelUserLogsRandom 37 | type ChannelUserLogsRandomParams struct { 38 | // in: path 39 | Channel string `json:"channel"` 40 | // in: path 41 | Username string `json:"username"` 42 | LogParams 43 | } 44 | 45 | //swagger:parameters channelUserLogs 46 | type ChannelUserLogsParams struct { 47 | // in: path 48 | Channel string `json:"channel"` 49 | // in: path 50 | Username string `json:"username"` 51 | LogParams 52 | } 53 | 54 | //swagger:parameters channelUserLogsYearMonth 55 | type ChannelUserLogsYearMonthParams struct { 56 | // in: path 57 | Channel string `json:"channel"` 58 | // in: path 59 | Username string `json:"username"` 60 | // in: path 61 | Year string `json:"year"` 62 | // in: path 63 | Month string `json:"month"` 64 | LogParams 65 | } 66 | 67 | //swagger:parameters channelLogs 68 | type ChannelLogsParams struct { 69 | // in: path 70 | Channel string `json:"channel"` 71 | LogParams 72 | } 73 | 74 | //swagger:parameters channelLogsYearMonthDay 75 | type ChannelLogsYearMonthDayParams struct { 76 | // in: path 77 | Channel string `json:"channel"` 78 | // in: path 79 | Year string `json:"year"` 80 | // in: path 81 | Month string `json:"month"` 82 | // in: path 83 | Day string `json:"day"` 84 | LogParams 85 | } 86 | 87 | //swagger:parameters channelIdUserIdLogsRandom 88 | type ChannelIdUserIdLogsRandomParams struct { 89 | // in: path 90 | ChannelId string `json:"channelid"` 91 | // in: path 92 | UserId string `json:"userid"` 93 | LogParams 94 | } 95 | 96 | //swagger:parameters channelIdUserIdLogs 97 | type ChannelIdUserIdLogsParams struct { 98 | // in: path 99 | ChannelId string `json:"channelid"` 100 | // in: path 101 | UserId string `json:"userid"` 102 | LogParams 103 | } 104 | 105 | //swagger:parameters channelIdUserIdLogsYearMonth 106 | type ChannelIdUserIdLogsYearMonthParams struct { 107 | // in: path 108 | ChannelId string `json:"channelid"` 109 | // in: path 110 | UserId string `json:"userid"` 111 | // in: path 112 | Year string `json:"year"` 113 | // in: path 114 | Month string `json:"month"` 115 | LogParams 116 | } 117 | 118 | //swagger:parameters channelIdLogs 119 | type ChannelIdLogsParams struct { 120 | // in: path 121 | Channel string `json:"channelid"` 122 | LogParams 123 | } 124 | 125 | //swagger:parameters channelIdLogsYearMonthDay 126 | type ChannelIdLogsYearMonthDayParams struct { 127 | // in: path 128 | Channel string `json:"channel"` 129 | // in: path 130 | Year string `json:"year"` 131 | // in: path 132 | Month string `json:"month"` 133 | // in: path 134 | Day string `json:"day"` 135 | LogParams 136 | } 137 | 138 | //swagger:parameters channelIdUserLogsRandom 139 | type ChannelIdUserLogsRandomParams struct { 140 | // in: path 141 | ChannelId string `json:"channelid"` 142 | // in: path 143 | Username string `json:"username"` 144 | LogParams 145 | } 146 | 147 | //swagger:parameters channelIdUserLogs 148 | type ChannelIdUserLogsParams struct { 149 | // in: path 150 | ChannelId string `json:"channelid"` 151 | // in: path 152 | Username string `json:"username"` 153 | LogParams 154 | } 155 | 156 | //swagger:parameters channelIdUserLogsYearMonth 157 | type ChannelIdUserLogsYearMonthParams struct { 158 | // in: path 159 | ChannelId string `json:"channelid"` 160 | // in: path 161 | Username string `json:"username"` 162 | // in: path 163 | Year string `json:"year"` 164 | // in: path 165 | Month string `json:"month"` 166 | LogParams 167 | } 168 | 169 | //swagger:parameters channelUserIdLogsRandom 170 | type ChannelUserIdLogsRandomParams struct { 171 | // in: path 172 | Channel string `json:"channel"` 173 | // in: path 174 | UserId string `json:"userid"` 175 | LogParams 176 | } 177 | 178 | //swagger:parameters channelUserIdLogs 179 | type ChannelUserIdLogsParams struct { 180 | // in: path 181 | Channel string `json:"channel"` 182 | // in: path 183 | UserId string `json:"userid"` 184 | LogParams 185 | } 186 | 187 | //swagger:parameters channelUserIdLogsYearMonth 188 | type ChannelUserIdLogsYearMonthParams struct { 189 | // in: path 190 | Channel string `json:"channel"` 191 | // in: path 192 | Userid string `json:"userid"` 193 | // in: path 194 | Year string `json:"year"` 195 | // in: path 196 | Month string `json:"month"` 197 | LogParams 198 | } 199 | 200 | // swagger:parameters addChannels 201 | type AddChannelsParameters struct { 202 | // in:body 203 | Body channelConfigsJoinRequest 204 | } 205 | 206 | // swagger:parameters deleteChannels 207 | type DeleteChannelsParameters struct { 208 | // in:body 209 | Body channelsDeleteRequest 210 | } 211 | 212 | //swagger:parameters list 213 | type ListLogsParams struct { 214 | // in: query 215 | Channel string `json:"channel"` 216 | // in: query 217 | Username string `json:"username"` 218 | // in: query 219 | ChannelId string `json:"channelid"` 220 | // in: query 221 | Userid string `json:"userid"` 222 | } 223 | -------------------------------------------------------------------------------- /api/logrequest.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type logRequest struct { 16 | channel string 17 | user string 18 | channelid string 19 | userid string 20 | time logTime 21 | reverse bool 22 | responseType string 23 | redirectPath string 24 | isUserRequest bool 25 | isChannelRequest bool 26 | } 27 | 28 | // userRandomMessageRequest /channel/pajlada/user/gempir/random 29 | 30 | type logTime struct { 31 | from string 32 | to string 33 | year string 34 | month string 35 | day string 36 | random bool 37 | } 38 | 39 | var ( 40 | pathRegex = regexp.MustCompile(`\/(channel|channelid)\/(\w+)(?:\/(user|userid)\/(\w+))?(?:(?:\/(\d{4})\/(\d{1,2})(?:\/(\d{1,2}))?)|(?:\/(random)))?`) 41 | ) 42 | 43 | func (s *Server) newLogRequestFromURL(r *http.Request) (logRequest, error) { 44 | path := r.URL.EscapedPath() 45 | 46 | if path != strings.ToLower(path) { 47 | return logRequest{redirectPath: fmt.Sprintf("%s?%s", strings.ToLower(path), r.URL.Query().Encode())}, nil 48 | } 49 | 50 | if !strings.HasPrefix(path, "/channel") && !strings.HasPrefix(path, "/channelid") { 51 | return logRequest{}, errors.New("route not found") 52 | } 53 | 54 | url := strings.TrimRight(path, "/") 55 | 56 | matches := pathRegex.FindAllStringSubmatch(url, -1) 57 | if len(matches) == 0 || len(matches[0]) < 5 { 58 | return logRequest{}, errors.New("route not found") 59 | } 60 | 61 | request := logRequest{ 62 | time: logTime{}, 63 | } 64 | 65 | params := []string{} 66 | for _, match := range matches[0] { 67 | if match != "" { 68 | params = append(params, match) 69 | } 70 | } 71 | 72 | request.isUserRequest = len(params) > 4 && (params[3] == "user" || params[3] == "userid") 73 | request.isChannelRequest = len(params) < 4 || (len(params) >= 4 && params[3] != "user" && params[3] != "userid") 74 | 75 | if params[1] == "channel" { 76 | request.channel = params[2] 77 | } 78 | if params[1] == "channelid" { 79 | request.channelid = params[2] 80 | } 81 | if request.isUserRequest && params[3] == "user" { 82 | request.user = params[4] 83 | } 84 | if request.isUserRequest && params[3] == "userid" { 85 | request.userid = params[4] 86 | } 87 | 88 | var err error 89 | request, err = s.fillIds(request) 90 | if err != nil { 91 | log.Error(err) 92 | return logRequest{}, nil 93 | } 94 | 95 | if request.isUserRequest && len(params) == 7 { 96 | request.time.year = params[5] 97 | request.time.month = params[6] 98 | } else if request.isUserRequest && len(params) == 8 { 99 | return logRequest{}, errors.New("route not found") 100 | } else if request.isChannelRequest && len(params) == 6 { 101 | request.time.year = params[3] 102 | request.time.month = params[4] 103 | request.time.day = params[5] 104 | } else if request.isUserRequest && len(params) == 6 && params[5] == "random" { 105 | request.time.random = true 106 | } else if request.isChannelRequest && len(params) == 4 && params[3] == "random" { 107 | request.time.random = true 108 | } else { 109 | if request.isChannelRequest { 110 | request.time.year = fmt.Sprintf("%d", time.Now().Year()) 111 | request.time.month = fmt.Sprintf("%d", time.Now().Month()) 112 | } else { 113 | year, month, err := s.fileLogger.GetLastLogYearAndMonthForUser(request.channelid, request.userid) 114 | if err == nil { 115 | request.time.year = fmt.Sprintf("%d", year) 116 | request.time.month = fmt.Sprintf("%d", month) 117 | } else { 118 | request.time.year = fmt.Sprintf("%d", time.Now().Year()) 119 | request.time.month = fmt.Sprintf("%d", time.Now().Month()) 120 | } 121 | } 122 | 123 | timePath := request.time.year + "/" + request.time.month 124 | 125 | if request.isChannelRequest { 126 | request.time.day = fmt.Sprintf("%d", time.Now().Day()) 127 | timePath += "/" + request.time.day 128 | } 129 | 130 | query := r.URL.Query() 131 | 132 | encodedQuery := "" 133 | if query.Encode() != "" { 134 | encodedQuery = "?" + query.Encode() 135 | } 136 | 137 | return logRequest{redirectPath: fmt.Sprintf("%s/%s%s", params[0], timePath, encodedQuery)}, nil 138 | } 139 | 140 | if r.URL.Query().Get("from") != "" || r.URL.Query().Get("to") != "" { 141 | request.time.from = r.URL.Query().Get("from") 142 | if request.time.from == "" { 143 | request.time.from = strconv.FormatInt(time.Now().Unix(), 10) 144 | } 145 | request.time.to = r.URL.Query().Get("to") 146 | if request.time.to == "" { 147 | request.time.to = strconv.FormatInt(time.Now().Unix(), 10) 148 | } 149 | } 150 | 151 | if _, ok := r.URL.Query()["reverse"]; ok { 152 | request.reverse = true 153 | } else { 154 | request.reverse = false 155 | } 156 | 157 | if _, ok := r.URL.Query()["json"]; ok || r.URL.Query().Get("type") == "json" || r.Header.Get("Content-Type") == "application/json" { 158 | request.responseType = responseTypeJSON 159 | } else if _, ok := r.URL.Query()["raw"]; ok || r.URL.Query().Get("type") == "raw" { 160 | request.responseType = responseTypeRaw 161 | } else { 162 | request.responseType = responseTypeText 163 | } 164 | 165 | return request, nil 166 | } 167 | 168 | func (s *Server) fillIds(request logRequest) (logRequest, error) { 169 | usernames := []string{} 170 | if request.channelid == "" && request.channel != "" { 171 | usernames = append(usernames, request.channel) 172 | } 173 | if request.userid == "" && request.user != "" { 174 | usernames = append(usernames, request.user) 175 | } 176 | 177 | ids, err := s.helixClient.GetUsersByUsernames(usernames) 178 | if err != nil { 179 | return request, err 180 | } 181 | 182 | if request.channelid == "" { 183 | request.channelid = ids[strings.ToLower(request.channel)].ID 184 | } 185 | if request.userid == "" { 186 | request.userid = ids[strings.ToLower(request.user)].ID 187 | } 188 | 189 | return request, nil 190 | } 191 | -------------------------------------------------------------------------------- /helix/user.go: -------------------------------------------------------------------------------- 1 | package helix 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | helixClient "github.com/nicklaw5/helix" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // Client wrapper for helix 14 | type Client struct { 15 | clientID string 16 | clientSecret string 17 | appAccessToken string 18 | client *helixClient.Client 19 | httpClient *http.Client 20 | } 21 | 22 | var ( 23 | userCacheByID sync.Map 24 | userCacheByUsername sync.Map 25 | ) 26 | 27 | type TwitchApiClient interface { 28 | GetUsersByUserIds([]string) (map[string]UserData, error) 29 | GetUsersByUsernames([]string) (map[string]UserData, error) 30 | } 31 | 32 | // NewClient Create helix client 33 | func NewClient(clientID string, clientSecret string) Client { 34 | client, err := helixClient.NewClient(&helixClient.Options{ 35 | ClientID: clientID, 36 | ClientSecret: clientSecret, 37 | }) 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | resp, err := client.RequestAppAccessToken([]string{}) 43 | if err != nil { 44 | panic(err) 45 | } 46 | log.Infof("Requested access token, response: %d, expires in: %d", resp.StatusCode, resp.Data.ExpiresIn) 47 | client.SetAppAccessToken(resp.Data.AccessToken) 48 | 49 | return Client{ 50 | clientID: clientID, 51 | clientSecret: clientSecret, 52 | appAccessToken: resp.Data.AccessToken, 53 | client: client, 54 | httpClient: &http.Client{}, 55 | } 56 | } 57 | 58 | // UserData exported data from twitch 59 | type UserData struct { 60 | ID string `json:"id"` 61 | Login string `json:"login"` 62 | DisplayName string `json:"display_name"` 63 | Type string `json:"type"` 64 | BroadcasterType string `json:"broadcaster_type"` 65 | Description string `json:"description"` 66 | ProfileImageURL string `json:"profile_image_url"` 67 | OfflineImageURL string `json:"offline_image_url"` 68 | ViewCount int `json:"view_count"` 69 | Email string `json:"email"` 70 | } 71 | 72 | // StartRefreshTokenRoutine refresh our token 73 | func (c *Client) StartRefreshTokenRoutine() { 74 | ticker := time.NewTicker(24 * time.Hour) 75 | 76 | for range ticker.C { 77 | resp, err := c.client.RequestAppAccessToken([]string{}) 78 | if err != nil { 79 | log.Error(err) 80 | continue 81 | } 82 | log.Infof("Requested access token from routine, response: %d, expires in: %d", resp.StatusCode, resp.Data.ExpiresIn) 83 | 84 | c.client.SetAppAccessToken(resp.Data.AccessToken) 85 | } 86 | } 87 | 88 | func chunkBy(items []string, chunkSize int) (chunks [][]string) { 89 | for chunkSize < len(items) { 90 | items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize]) 91 | } 92 | 93 | return append(chunks, items) 94 | } 95 | 96 | // GetUsersByUserIds receive userData for given ids 97 | func (c *Client) GetUsersByUserIds(userIDs []string) (map[string]UserData, error) { 98 | var filteredUserIDs []string 99 | 100 | for _, id := range userIDs { 101 | if _, ok := userCacheByID.Load(id); !ok { 102 | filteredUserIDs = append(filteredUserIDs, id) 103 | } 104 | } 105 | 106 | if len(filteredUserIDs) > 0 { 107 | chunks := chunkBy(filteredUserIDs, 100) 108 | 109 | for _, chunk := range chunks { 110 | resp, err := c.client.GetUsers(&helixClient.UsersParams{ 111 | IDs: chunk, 112 | }) 113 | if err != nil { 114 | return map[string]UserData{}, err 115 | } 116 | 117 | log.Infof("%d GetUsersByUserIds %v", resp.StatusCode, chunk) 118 | 119 | for _, user := range resp.Data.Users { 120 | data := &UserData{ 121 | ID: user.ID, 122 | Login: user.Login, 123 | DisplayName: user.Login, 124 | Type: user.Type, 125 | BroadcasterType: user.BroadcasterType, 126 | Description: user.Description, 127 | ProfileImageURL: user.ProfileImageURL, 128 | OfflineImageURL: user.OfflineImageURL, 129 | ViewCount: user.ViewCount, 130 | Email: user.Email, 131 | } 132 | userCacheByID.Store(user.ID, data) 133 | userCacheByUsername.Store(user.Login, data) 134 | } 135 | } 136 | } 137 | 138 | result := make(map[string]UserData) 139 | 140 | for _, id := range userIDs { 141 | value, ok := userCacheByID.Load(id) 142 | if !ok { 143 | log.Debugf("Could not find userId, channel might be banned: %s", id) 144 | continue 145 | } 146 | result[id] = *(value.(*UserData)) 147 | } 148 | 149 | return result, nil 150 | } 151 | 152 | // GetUsersByUsernames fetches userdata from helix 153 | func (c *Client) GetUsersByUsernames(usernames []string) (map[string]UserData, error) { 154 | var filteredUsernames []string 155 | 156 | for _, username := range usernames { 157 | username = strings.ToLower(username) 158 | if _, ok := userCacheByUsername.Load(username); !ok { 159 | filteredUsernames = append(filteredUsernames, username) 160 | } 161 | } 162 | 163 | if len(filteredUsernames) > 0 { 164 | chunks := chunkBy(filteredUsernames, 100) 165 | 166 | for _, chunk := range chunks { 167 | resp, err := c.client.GetUsers(&helixClient.UsersParams{ 168 | Logins: chunk, 169 | }) 170 | if err != nil { 171 | return map[string]UserData{}, err 172 | } 173 | 174 | log.Infof("%d GetUsersByUsernames %v", resp.StatusCode, chunk) 175 | 176 | for _, user := range resp.Data.Users { 177 | data := &UserData{ 178 | ID: user.ID, 179 | Login: user.Login, 180 | DisplayName: user.Login, 181 | Type: user.Type, 182 | BroadcasterType: user.BroadcasterType, 183 | Description: user.Description, 184 | ProfileImageURL: user.ProfileImageURL, 185 | OfflineImageURL: user.OfflineImageURL, 186 | ViewCount: user.ViewCount, 187 | Email: user.Email, 188 | } 189 | userCacheByID.Store(user.ID, data) 190 | userCacheByUsername.Store(user.Login, data) 191 | } 192 | } 193 | } 194 | 195 | result := make(map[string]UserData) 196 | 197 | for _, username := range usernames { 198 | username = strings.ToLower(username) 199 | value, ok := userCacheByUsername.Load(username) 200 | if !ok { 201 | log.Debugf("Could not find username, channel might be banned: %s", username) 202 | continue 203 | } 204 | result[username] = *(value.(*UserData)) 205 | } 206 | 207 | return result, nil 208 | } 209 | -------------------------------------------------------------------------------- /bot/main.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gempir/justlog/config" 10 | "github.com/gempir/justlog/filelog" 11 | expiremap "github.com/nursik/go-expire-map" 12 | 13 | twitch "github.com/gempir/go-twitch-irc/v3" 14 | "github.com/gempir/justlog/helix" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | // Bot basic logging bot 19 | type Bot struct { 20 | startTime time.Time 21 | cfg *config.Config 22 | helixClient helix.TwitchApiClient 23 | logger filelog.Logger 24 | worker []*worker 25 | channels map[string]helix.UserData 26 | clearchats sync.Map 27 | OptoutCodes sync.Map 28 | msgMap *expiremap.ExpireMap 29 | } 30 | 31 | type worker struct { 32 | client *twitch.Client 33 | joinedChannels map[string]bool 34 | } 35 | 36 | func newWorker(client *twitch.Client) *worker { 37 | return &worker{ 38 | client: client, 39 | joinedChannels: map[string]bool{}, 40 | } 41 | } 42 | 43 | // NewBot create new bot instance 44 | func NewBot(cfg *config.Config, helixClient helix.TwitchApiClient, fileLogger filelog.Logger) *Bot { 45 | channels, err := helixClient.GetUsersByUserIds(cfg.Channels) 46 | if err != nil { 47 | log.Fatalf("[bot] failed to load configured channels %s", err.Error()) 48 | } 49 | 50 | return &Bot{ 51 | cfg: cfg, 52 | helixClient: helixClient, 53 | logger: fileLogger, 54 | channels: channels, 55 | worker: []*worker{}, 56 | OptoutCodes: sync.Map{}, 57 | msgMap: expiremap.New(), 58 | } 59 | } 60 | 61 | func (b *Bot) Say(channel, text string) { 62 | randomIndex := rand.Intn(len(b.worker)) 63 | b.worker[randomIndex].client.Say(channel, text) 64 | } 65 | 66 | // Connect startup the logger and bot 67 | func (b *Bot) Connect() { 68 | b.startTime = time.Now() 69 | client := b.newClient() 70 | go b.startJoinLoop() 71 | 72 | if strings.HasPrefix(b.cfg.Username, "justinfan") { 73 | log.Info("[bot] joining as anonymous user") 74 | } else { 75 | log.Info("[bot] joining as user " + b.cfg.Username) 76 | } 77 | 78 | defer b.msgMap.Close() 79 | log.Fatal(client.Connect()) 80 | } 81 | 82 | // constantly join channels to rejoin some channels that got unbanned over time 83 | func (b *Bot) startJoinLoop() { 84 | for { 85 | for _, channel := range b.channels { 86 | b.Join(channel.Login) 87 | } 88 | 89 | time.Sleep(time.Hour * 1) 90 | log.Info("[bot] running hourly join loop") 91 | } 92 | } 93 | 94 | func (b *Bot) Part(channelNames ...string) { 95 | for _, channelName := range channelNames { 96 | log.Info("[bot] leaving " + channelName) 97 | 98 | for _, worker := range b.worker { 99 | worker.client.Depart(channelName) 100 | } 101 | } 102 | } 103 | 104 | func (b *Bot) Join(channelNames ...string) { 105 | for _, channel := range channelNames { 106 | channel = strings.ToLower(channel) 107 | 108 | joined := false 109 | 110 | for _, worker := range b.worker { 111 | if _, ok := worker.joinedChannels[channel]; ok { 112 | // already joined but join again in case it was a temporary ban 113 | worker.client.Join(channel) 114 | joined = true 115 | } 116 | } 117 | 118 | for _, worker := range b.worker { 119 | if len(worker.joinedChannels) < 50 { 120 | log.Info("[bot] joining " + channel) 121 | worker.client.Join(channel) 122 | worker.joinedChannels[channel] = true 123 | joined = true 124 | break 125 | } 126 | } 127 | 128 | if !joined { 129 | client := b.newClient() 130 | go client.Connect() 131 | b.Join(channel) 132 | } 133 | } 134 | } 135 | 136 | func (b *Bot) newClient() *twitch.Client { 137 | client := twitch.NewClient(b.cfg.Username, "oauth:"+b.cfg.OAuth) 138 | if b.cfg.BotVerified { 139 | client.SetJoinRateLimiter(twitch.CreateVerifiedRateLimiter()) 140 | } 141 | 142 | b.worker = append(b.worker, newWorker(client)) 143 | log.Infof("[bot] creating new twitch connection, new total: %d", len(b.worker)) 144 | 145 | client.OnPrivateMessage(b.handlePrivateMessage) 146 | client.OnUserNoticeMessage(b.handleUserNotice) 147 | client.OnClearChatMessage(b.handleClearChat) 148 | 149 | return client 150 | } 151 | 152 | func (b *Bot) handlePrivateMessage(message twitch.PrivateMessage) { 153 | if _, ok := b.msgMap.Get(message.ID); ok { 154 | return 155 | } 156 | b.msgMap.Set(message.ID, true, time.Second*3) 157 | 158 | b.handlePrivateMessageCommands(message) 159 | 160 | if b.cfg.IsOptedOut(message.User.ID) || b.cfg.IsOptedOut(message.RoomID) { 161 | return 162 | } 163 | 164 | go func() { 165 | err := b.logger.LogPrivateMessageForUser(message.User, message) 166 | if err != nil { 167 | log.Error(err.Error()) 168 | } 169 | }() 170 | 171 | go func() { 172 | err := b.logger.LogPrivateMessageForChannel(message) 173 | if err != nil { 174 | log.Error(err.Error()) 175 | } 176 | }() 177 | } 178 | 179 | func (b *Bot) handleUserNotice(message twitch.UserNoticeMessage) { 180 | if _, ok := b.msgMap.Get(message.ID); ok { 181 | return 182 | } 183 | b.msgMap.Set(message.ID, true, time.Second*3) 184 | 185 | if b.cfg.IsOptedOut(message.User.ID) || b.cfg.IsOptedOut(message.RoomID) { 186 | return 187 | } 188 | 189 | go func() { 190 | err := b.logger.LogUserNoticeMessageForUser(message.User.ID, message) 191 | if err != nil { 192 | log.Error(err.Error()) 193 | } 194 | }() 195 | 196 | if _, ok := message.Tags["msg-param-recipient-id"]; ok { 197 | go func() { 198 | err := b.logger.LogUserNoticeMessageForUser(message.Tags["msg-param-recipient-id"], message) 199 | if err != nil { 200 | log.Error(err.Error()) 201 | } 202 | }() 203 | } 204 | 205 | go func() { 206 | err := b.logger.LogUserNoticeMessageForChannel(message) 207 | if err != nil { 208 | log.Error(err.Error()) 209 | } 210 | }() 211 | } 212 | 213 | func (b *Bot) handleClearChat(message twitch.ClearChatMessage) { 214 | if b.cfg.IsOptedOut(message.TargetUserID) || b.cfg.IsOptedOut(message.RoomID) { 215 | return 216 | } 217 | 218 | if message.BanDuration == 0 { 219 | count, ok := b.clearchats.Load(message.RoomID) 220 | if !ok { 221 | count = 0 222 | } 223 | newCount := count.(int) + 1 224 | b.clearchats.Store(message.RoomID, newCount) 225 | 226 | go func() { 227 | time.Sleep(time.Second * 1) 228 | count, ok := b.clearchats.Load(message.RoomID) 229 | if ok { 230 | b.clearchats.Store(message.RoomID, count.(int)-1) 231 | } 232 | }() 233 | 234 | if newCount > 50 { 235 | if newCount == 51 { 236 | log.Infof("Stopped recording CLEARCHAT permabans in: %s", message.Channel) 237 | } 238 | return 239 | } 240 | } 241 | 242 | go func() { 243 | err := b.logger.LogClearchatMessageForUser(message.TargetUserID, message) 244 | if err != nil { 245 | log.Error(err.Error()) 246 | } 247 | }() 248 | 249 | go func() { 250 | err := b.logger.LogClearchatMessageForChannel(message) 251 | if err != nil { 252 | log.Error(err.Error()) 253 | } 254 | }() 255 | } 256 | -------------------------------------------------------------------------------- /api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/gempir/go-twitch-irc/v3" 10 | ) 11 | 12 | // RandomQuoteJSON response when request a random message 13 | type RandomQuoteJSON struct { 14 | Channel string `json:"channel"` 15 | Username string `json:"username"` 16 | DisplayName string `json:"displayName"` 17 | Message string `json:"message"` 18 | Timestamp timestamp `json:"timestamp"` 19 | } 20 | 21 | // swagger:route GET /channel/{channel}/user/{username}/random logs channelUserLogsRandom 22 | // 23 | // Get a random line from a user in a given channel 24 | // 25 | // Produces: 26 | // - application/json 27 | // - text/plain 28 | // 29 | // Responses: 30 | // 200: chatLog 31 | 32 | // swagger:route GET /channelid/{channelid}/userid/{userid}/random logs channelIdUserIdLogsRandom 33 | // 34 | // Get a random line from a user in a given channel 35 | // 36 | // Produces: 37 | // - application/json 38 | // - text/plain 39 | // 40 | // Responses: 41 | // 200: chatLog 42 | 43 | // swagger:route GET /channelid/{channelid}/user/{user}/random logs channelIdUserLogsRandom 44 | // 45 | // Get a random line from a user in a given channel 46 | // 47 | // Produces: 48 | // - application/json 49 | // - text/plain 50 | // 51 | // Responses: 52 | // 200: chatLog 53 | 54 | // swagger:route GET /channel/{channel}/userid/{userid}/random logs channelUserIdLogsRandom 55 | // 56 | // Get a random line from a user in a given channel 57 | // 58 | // Produces: 59 | // - application/json 60 | // - text/plain 61 | // 62 | // Responses: 63 | // 200: chatLog 64 | func (s *Server) getRandomQuote(request logRequest) (*chatLog, error) { 65 | rawMessage, err := s.fileLogger.ReadRandomMessageForUser(request.channelid, request.userid) 66 | if err != nil { 67 | return &chatLog{}, err 68 | } 69 | parsedMessage := twitch.ParseMessage(rawMessage) 70 | 71 | chatMsg := createChatMessage(parsedMessage) 72 | 73 | return &chatLog{Messages: []chatMessage{chatMsg}}, nil 74 | } 75 | 76 | // swagger:route GET /list logs list 77 | // 78 | // 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. 79 | // 80 | // Produces: 81 | // - application/json 82 | // - text/plain 83 | // 84 | // Schemes: https 85 | // 86 | // Responses: 87 | // 200: logList 88 | func (s *Server) writeAvailableLogs(w http.ResponseWriter, r *http.Request, q url.Values) { 89 | channelid := q.Get("channelid") 90 | userid := q.Get("userid") 91 | 92 | if userid == "" { 93 | logs, err := s.fileLogger.GetAvailableLogsForChannel(channelid) 94 | if err != nil { 95 | http.Error(w, "failed to get available channel logs: "+err.Error(), http.StatusNotFound) 96 | return 97 | } 98 | 99 | writeCacheControl(w, r, time.Hour) 100 | writeJSON(&channelLogList{logs}, http.StatusOK, w, r) 101 | return 102 | } 103 | 104 | logs, err := s.fileLogger.GetAvailableLogsForUser(channelid, userid) 105 | if err != nil { 106 | http.Error(w, "failed to get available user logs: "+err.Error(), http.StatusNotFound) 107 | return 108 | } 109 | 110 | writeCacheControl(w, r, time.Hour) 111 | writeJSON(&logList{logs}, http.StatusOK, w, r) 112 | } 113 | 114 | // swagger:route GET /channel/{channel}/user/{username} logs channelUserLogs 115 | // 116 | // Get user logs in channel of current month 117 | // 118 | // Produces: 119 | // - application/json 120 | // - text/plain 121 | // 122 | // Responses: 123 | // 200: chatLog 124 | 125 | // swagger:route GET /channelid/{channelid}/userid/{userid} logs channelIdUserIdLogs 126 | // 127 | // Get user logs in channel of current month 128 | // 129 | // Produces: 130 | // - application/json 131 | // - text/plain 132 | // 133 | // Responses: 134 | // 200: chatLog 135 | 136 | // swagger:route GET /channelid/{channelid}/user/{username} logs channelIdUserLogs 137 | // 138 | // Get user logs in channel of current month 139 | // 140 | // Produces: 141 | // - application/json 142 | // - text/plain 143 | // 144 | // Responses: 145 | // 200: chatLog 146 | 147 | // swagger:route GET /channel/{channel}/userid/{userid} logs channelUserIdLogs 148 | // 149 | // Get user logs in channel of current month 150 | // 151 | // Produces: 152 | // - application/json 153 | // - text/plain 154 | // 155 | // Responses: 156 | // 200: chatLog 157 | 158 | // swagger:route GET /channel/{channel}/user/{username}/{year}/{month} logs channelUserLogsYearMonth 159 | // 160 | // Get user logs in channel of given year month 161 | // 162 | // Produces: 163 | // - application/json 164 | // - text/plain 165 | // 166 | // Responses: 167 | // 200: chatLog 168 | 169 | // swagger:route GET /channelid/{channelid}/userid/{userid}/{year}/{month} logs channelIdUserIdLogsYearMonth 170 | // 171 | // Get user logs in channel of given year month 172 | // 173 | // Produces: 174 | // - application/json 175 | // - text/plain 176 | // 177 | // Responses: 178 | // 200: chatLog 179 | 180 | // swagger:route GET /channelid/{channelid}/user/{username}/{year}/{month} logs channelIdUserLogsYearMonth 181 | // 182 | // Get user logs in channel of given year month 183 | // 184 | // Produces: 185 | // - application/json 186 | // - text/plain 187 | // 188 | // Responses: 189 | // 200: chatLog 190 | 191 | // swagger:route GET /channel/{channel}/userid/{userid}/{year}/{month} logs channelUserIdLogsYearMonth 192 | // 193 | // Get user logs in channel of given year month 194 | // 195 | // Produces: 196 | // - application/json 197 | // - text/plain 198 | // 199 | // Responses: 200 | // 200: chatLog 201 | func (s *Server) getUserLogs(request logRequest) (*chatLog, error) { 202 | logMessages, err := s.fileLogger.ReadLogForUser(request.channelid, request.userid, request.time.year, request.time.month) 203 | if err != nil { 204 | return &chatLog{}, err 205 | } 206 | 207 | if request.reverse { 208 | reverseSlice(logMessages) 209 | } 210 | 211 | logResult := createLogResult() 212 | 213 | for _, rawMessage := range logMessages { 214 | logResult.Messages = append(logResult.Messages, createChatMessage(twitch.ParseMessage(rawMessage))) 215 | } 216 | 217 | return &logResult, nil 218 | } 219 | 220 | func (s *Server) getUserLogsRange(request logRequest) (*chatLog, error) { 221 | 222 | fromTime, toTime, err := parseFromTo(request.time.from, request.time.to, userHourLimit) 223 | if err != nil { 224 | return &chatLog{}, err 225 | } 226 | 227 | var logMessages []string 228 | 229 | logMessages, _ = s.fileLogger.ReadLogForUser(request.channelid, request.userid, fmt.Sprintf("%d", fromTime.Year()), fmt.Sprintf("%d", int(fromTime.Month()))) 230 | 231 | if fromTime.Month() != toTime.Month() { 232 | additionalMessages, _ := s.fileLogger.ReadLogForUser(request.channelid, request.userid, fmt.Sprint(toTime.Year()), fmt.Sprint(toTime.Month())) 233 | 234 | logMessages = append(logMessages, additionalMessages...) 235 | } 236 | 237 | if request.reverse { 238 | reverseSlice(logMessages) 239 | } 240 | 241 | logResult := createLogResult() 242 | 243 | for _, rawMessage := range logMessages { 244 | parsedMessage := twitch.ParseMessage(rawMessage) 245 | 246 | switch message := parsedMessage.(type) { 247 | case *twitch.PrivateMessage: 248 | if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() { 249 | continue 250 | } 251 | case *twitch.ClearChatMessage: 252 | if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() { 253 | continue 254 | } 255 | case *twitch.UserNoticeMessage: 256 | if message.Time.Unix() < fromTime.Unix() || message.Time.Unix() > toTime.Unix() { 257 | continue 258 | } 259 | } 260 | 261 | logResult.Messages = append(logResult.Messages, createChatMessage(parsedMessage)) 262 | } 263 | 264 | return &logResult, nil 265 | } 266 | -------------------------------------------------------------------------------- /filelog/userlog.go: -------------------------------------------------------------------------------- 1 | package filelog 2 | 3 | import ( 4 | "bufio" 5 | "compress/gzip" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "math/rand" 11 | "os" 12 | "sort" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/gempir/go-twitch-irc/v3" 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | type Logger interface { 21 | LogPrivateMessageForUser(user twitch.User, message twitch.PrivateMessage) error 22 | LogClearchatMessageForUser(userID string, message twitch.ClearChatMessage) error 23 | LogUserNoticeMessageForUser(userID string, message twitch.UserNoticeMessage) error 24 | GetLastLogYearAndMonthForUser(channelID, userID string) (int, int, error) 25 | GetAvailableLogsForUser(channelID, userID string) ([]UserLogFile, error) 26 | ReadLogForUser(channelID, userID string, year string, month string) ([]string, error) 27 | ReadRandomMessageForUser(channelID, userID string) (string, error) 28 | 29 | LogPrivateMessageForChannel(message twitch.PrivateMessage) error 30 | LogClearchatMessageForChannel(message twitch.ClearChatMessage) error 31 | LogUserNoticeMessageForChannel(message twitch.UserNoticeMessage) error 32 | ReadLogForChannel(channelID string, year int, month int, day int) ([]string, error) 33 | ReadRandomMessageForChannel(channelID string) (string, error) 34 | GetAvailableLogsForChannel(channelID string) ([]ChannelLogFile, error) 35 | } 36 | 37 | type FileLogger struct { 38 | logPath string 39 | } 40 | 41 | func NewFileLogger(logPath string) FileLogger { 42 | return FileLogger{ 43 | logPath: logPath, 44 | } 45 | } 46 | 47 | func (l *FileLogger) LogPrivateMessageForUser(user twitch.User, message twitch.PrivateMessage) error { 48 | year := message.Time.Year() 49 | month := int(message.Time.Month()) 50 | 51 | err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/", message.RoomID, year, month), 0750) 52 | if err != nil { 53 | return err 54 | } 55 | filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%s.txt", message.RoomID, year, month, user.ID) 56 | 57 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640) 58 | if err != nil { 59 | return err 60 | } 61 | defer file.Close() 62 | 63 | if _, err = file.WriteString(message.Raw + "\n"); err != nil { 64 | return err 65 | } 66 | return nil 67 | } 68 | 69 | func (l *FileLogger) LogClearchatMessageForUser(userID string, message twitch.ClearChatMessage) error { 70 | year := message.Time.Year() 71 | month := int(message.Time.Month()) 72 | 73 | err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/", message.RoomID, year, month), 0750) 74 | if err != nil { 75 | return err 76 | } 77 | filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%s.txt", message.RoomID, year, month, userID) 78 | 79 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640) 80 | if err != nil { 81 | return err 82 | } 83 | defer file.Close() 84 | 85 | if _, err = file.WriteString(message.Raw + "\n"); err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | 91 | func (l *FileLogger) LogUserNoticeMessageForUser(userID string, message twitch.UserNoticeMessage) error { 92 | year := message.Time.Year() 93 | month := int(message.Time.Month()) 94 | 95 | err := os.MkdirAll(fmt.Sprintf(l.logPath+"/%s/%d/%d/", message.RoomID, year, month), 0750) 96 | if err != nil { 97 | return err 98 | } 99 | filename := fmt.Sprintf(l.logPath+"/%s/%d/%d/%s.txt", message.RoomID, year, month, userID) 100 | 101 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640) 102 | if err != nil { 103 | return err 104 | } 105 | defer file.Close() 106 | 107 | if _, err = file.WriteString(message.Raw + "\n"); err != nil { 108 | return err 109 | } 110 | return nil 111 | } 112 | 113 | type UserLogFile struct { 114 | path string 115 | Year string `json:"year"` 116 | Month string `json:"month"` 117 | } 118 | 119 | func (l *FileLogger) GetLastLogYearAndMonthForUser(channelID, userID string) (int, int, error) { 120 | if channelID == "" || userID == "" { 121 | return 0, 0, fmt.Errorf("Invalid channelID: %s or userID: %s", channelID, userID) 122 | } 123 | 124 | logFiles := []UserLogFile{} 125 | 126 | years, _ := ioutil.ReadDir(l.logPath + "/" + channelID) 127 | 128 | for _, yearDir := range years { 129 | year := yearDir.Name() 130 | months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/") 131 | for _, monthDir := range months { 132 | month := monthDir.Name() 133 | path := fmt.Sprintf("%s/%s/%s/%s/%s.txt", l.logPath, channelID, year, month, userID) 134 | if _, err := os.Stat(path); err == nil { 135 | 136 | logFile := UserLogFile{path, year, month} 137 | logFiles = append(logFiles, logFile) 138 | } else if _, err := os.Stat(path + ".gz"); err == nil { 139 | logFile := UserLogFile{path + ".gz", year, month} 140 | logFiles = append(logFiles, logFile) 141 | } 142 | } 143 | } 144 | 145 | sort.Slice(logFiles, func(i, j int) bool { 146 | yearA, _ := strconv.Atoi(logFiles[i].Year) 147 | yearB, _ := strconv.Atoi(logFiles[j].Year) 148 | monthA, _ := strconv.Atoi(logFiles[i].Month) 149 | monthB, _ := strconv.Atoi(logFiles[j].Month) 150 | 151 | if yearA == yearB { 152 | return monthA > monthB 153 | } 154 | 155 | return yearA > yearB 156 | }) 157 | 158 | if len(logFiles) > 0 { 159 | year, _ := strconv.Atoi(logFiles[0].Year) 160 | month, _ := strconv.Atoi(logFiles[0].Month) 161 | 162 | return year, month, nil 163 | } 164 | 165 | return 0, 0, errors.New("No logs file") 166 | } 167 | 168 | func (l *FileLogger) GetAvailableLogsForUser(channelID, userID string) ([]UserLogFile, error) { 169 | if channelID == "" || userID == "" { 170 | return []UserLogFile{}, fmt.Errorf("Invalid channelID: %s or userID: %s", channelID, userID) 171 | } 172 | 173 | logFiles := []UserLogFile{} 174 | 175 | years, _ := ioutil.ReadDir(l.logPath + "/" + channelID) 176 | 177 | for _, yearDir := range years { 178 | year := yearDir.Name() 179 | months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/") 180 | for _, monthDir := range months { 181 | month := monthDir.Name() 182 | path := fmt.Sprintf("%s/%s/%s/%s/%s.txt", l.logPath, channelID, year, month, userID) 183 | if _, err := os.Stat(path); err == nil { 184 | 185 | logFile := UserLogFile{path, year, month} 186 | logFiles = append(logFiles, logFile) 187 | } else if _, err := os.Stat(path + ".gz"); err == nil { 188 | logFile := UserLogFile{path + ".gz", year, month} 189 | logFiles = append(logFiles, logFile) 190 | } 191 | } 192 | } 193 | 194 | sort.Slice(logFiles, func(i, j int) bool { 195 | yearA, _ := strconv.Atoi(logFiles[i].Year) 196 | yearB, _ := strconv.Atoi(logFiles[j].Year) 197 | monthA, _ := strconv.Atoi(logFiles[i].Month) 198 | monthB, _ := strconv.Atoi(logFiles[j].Month) 199 | 200 | if yearA == yearB { 201 | return monthA > monthB 202 | } 203 | 204 | return yearA > yearB 205 | }) 206 | 207 | if len(logFiles) > 0 { 208 | return logFiles, nil 209 | } 210 | 211 | return logFiles, errors.New("No logs file") 212 | } 213 | 214 | type ChannelLogFile struct { 215 | path string 216 | Year string `json:"year"` 217 | Month string `json:"month"` 218 | Day string `json:"day"` 219 | } 220 | 221 | func (l *FileLogger) GetAvailableLogsForChannel(channelID string) ([]ChannelLogFile, error) { 222 | if channelID == "" { 223 | return []ChannelLogFile{}, fmt.Errorf("Invalid channelID: %s", channelID) 224 | } 225 | 226 | logFiles := []ChannelLogFile{} 227 | 228 | years, _ := ioutil.ReadDir(l.logPath + "/" + channelID) 229 | 230 | for _, yearDir := range years { 231 | year := yearDir.Name() 232 | months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/") 233 | for _, monthDir := range months { 234 | month := monthDir.Name() 235 | 236 | days, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/" + month + "/") 237 | for _, dayDir := range days { 238 | day := dayDir.Name() 239 | path := fmt.Sprintf("%s/%s/%s/%s/%s/channel.txt", l.logPath, channelID, year, month, day) 240 | 241 | if _, err := os.Stat(path); err == nil { 242 | logFile := ChannelLogFile{path, year, month, day} 243 | logFiles = append(logFiles, logFile) 244 | } else if _, err := os.Stat(path + ".gz"); err == nil { 245 | logFile := ChannelLogFile{path + ".gz", year, month, day} 246 | logFiles = append(logFiles, logFile) 247 | } 248 | } 249 | } 250 | } 251 | 252 | sort.Slice(logFiles, func(i, j int) bool { 253 | yearA, _ := strconv.Atoi(logFiles[i].Year) 254 | yearB, _ := strconv.Atoi(logFiles[j].Year) 255 | monthA, _ := strconv.Atoi(logFiles[i].Month) 256 | monthB, _ := strconv.Atoi(logFiles[j].Month) 257 | dayA, _ := strconv.Atoi(logFiles[j].Day) 258 | dayB, _ := strconv.Atoi(logFiles[j].Day) 259 | 260 | if yearA == yearB { 261 | if monthA == monthB { 262 | return dayA > dayB 263 | } 264 | 265 | return monthA > monthB 266 | } 267 | 268 | if monthA == monthB { 269 | return dayA > dayB 270 | } 271 | 272 | return yearA > yearB 273 | }) 274 | 275 | if len(logFiles) > 0 { 276 | return logFiles, nil 277 | } 278 | 279 | return logFiles, errors.New("No logs file") 280 | } 281 | 282 | // ReadLogForUser fetch logs 283 | func (l *FileLogger) ReadLogForUser(channelID, userID string, year string, month string) ([]string, error) { 284 | if channelID == "" || userID == "" { 285 | return []string{}, fmt.Errorf("Invalid channelID: %s or userID: %s", channelID, userID) 286 | } 287 | 288 | filename := fmt.Sprintf(l.logPath+"/%s/%s/%s/%s.txt", channelID, year, month, userID) 289 | 290 | if _, err := os.Stat(filename); err != nil { 291 | filename = filename + ".gz" 292 | } 293 | 294 | log.Debug("Opening " + filename) 295 | f, err := os.Open(filename) 296 | if err != nil { 297 | return []string{}, errors.New("file not found: " + filename) 298 | } 299 | defer f.Close() 300 | 301 | var reader io.Reader 302 | 303 | if strings.HasSuffix(filename, ".gz") { 304 | gz, err := gzip.NewReader(f) 305 | if err != nil { 306 | log.Error(err) 307 | return []string{}, errors.New("file gzip not readable") 308 | } 309 | reader = gz 310 | } else { 311 | reader = f 312 | } 313 | 314 | scanner := bufio.NewScanner(reader) 315 | if err != nil { 316 | log.Error(err) 317 | return []string{}, errors.New("file not readable") 318 | } 319 | 320 | content := []string{} 321 | 322 | for scanner.Scan() { 323 | line := scanner.Text() 324 | content = append(content, line) 325 | } 326 | 327 | return content, nil 328 | } 329 | 330 | func (l *FileLogger) ReadRandomMessageForUser(channelID, userID string) (string, error) { 331 | var userLogs []string 332 | var lines []string 333 | 334 | if channelID == "" || userID == "" { 335 | return "", errors.New("missing channelID or userID") 336 | } 337 | 338 | years, _ := ioutil.ReadDir(l.logPath + "/" + channelID) 339 | for _, yearDir := range years { 340 | year := yearDir.Name() 341 | months, _ := ioutil.ReadDir(l.logPath + "/" + channelID + "/" + year + "/") 342 | for _, monthDir := range months { 343 | month := monthDir.Name() 344 | path := fmt.Sprintf("%s/%s/%s/%s/%s.txt", l.logPath, channelID, year, month, userID) 345 | if _, err := os.Stat(path); err == nil { 346 | userLogs = append(userLogs, path) 347 | } else if _, err := os.Stat(path + ".gz"); err == nil { 348 | userLogs = append(userLogs, path+".gz") 349 | } 350 | } 351 | } 352 | 353 | if len(userLogs) < 1 { 354 | return "", errors.New("no log found") 355 | } 356 | 357 | for _, logFile := range userLogs { 358 | f, _ := os.Open(logFile) 359 | 360 | scanner := bufio.NewScanner(f) 361 | 362 | if strings.HasSuffix(logFile, ".gz") { 363 | gz, _ := gzip.NewReader(f) 364 | scanner = bufio.NewScanner(gz) 365 | } 366 | 367 | for scanner.Scan() { 368 | line := scanner.Text() 369 | lines = append(lines, line) 370 | } 371 | f.Close() 372 | } 373 | 374 | ranNum := rand.Intn(len(lines)) 375 | 376 | return lines[ranNum], nil 377 | } 378 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/gempir/justlog/bot" 15 | 16 | "github.com/gempir/justlog/config" 17 | 18 | "github.com/gempir/justlog/helix" 19 | log "github.com/sirupsen/logrus" 20 | 21 | "github.com/gempir/go-twitch-irc/v3" 22 | "github.com/gempir/justlog/filelog" 23 | ) 24 | 25 | // Server api server 26 | type Server struct { 27 | listenAddress string 28 | logPath string 29 | bot *bot.Bot 30 | cfg *config.Config 31 | fileLogger filelog.Logger 32 | helixClient helix.TwitchApiClient 33 | assetsHandler http.Handler 34 | } 35 | 36 | // NewServer create api Server 37 | func NewServer(cfg *config.Config, bot *bot.Bot, fileLogger filelog.Logger, helixClient helix.TwitchApiClient, assets fs.FS) Server { 38 | build, err := fs.Sub(assets, "web/dist") 39 | if err != nil { 40 | log.Fatal("failed to read public assets") 41 | } 42 | 43 | return Server{ 44 | listenAddress: cfg.ListenAddress, 45 | bot: bot, 46 | logPath: cfg.LogsDirectory, 47 | cfg: cfg, 48 | fileLogger: fileLogger, 49 | helixClient: helixClient, 50 | assetsHandler: http.FileServer(http.FS(build)), 51 | } 52 | } 53 | 54 | const ( 55 | responseTypeJSON = "json" 56 | responseTypeText = "text" 57 | responseTypeRaw = "raw" 58 | ) 59 | 60 | var ( 61 | userHourLimit = 744.0 62 | channelHourLimit = 24.0 63 | ) 64 | 65 | type channel struct { 66 | UserID string `json:"userID"` 67 | Name string `json:"name"` 68 | } 69 | 70 | // swagger:model 71 | type AllChannelsJSON struct { 72 | Channels []channel `json:"channels"` 73 | } 74 | 75 | // swagger:model 76 | type chatLog struct { 77 | Messages []chatMessage `json:"messages"` 78 | } 79 | 80 | // swagger:model 81 | type logList struct { 82 | AvailableLogs []filelog.UserLogFile `json:"availableLogs"` 83 | } 84 | 85 | // swagger:model 86 | type channelLogList struct { 87 | AvailableLogs []filelog.ChannelLogFile `json:"availableLogs"` 88 | } 89 | 90 | type chatMessage struct { 91 | Text string `json:"text"` 92 | SystemText string `json:"systemText"` 93 | Username string `json:"username"` 94 | DisplayName string `json:"displayName"` 95 | Channel string `json:"channel"` 96 | Timestamp timestamp `json:"timestamp"` 97 | ID string `json:"id"` 98 | Type twitch.MessageType `json:"type"` 99 | Raw string `json:"raw"` 100 | Tags map[string]string `json:"tags"` 101 | } 102 | 103 | // ErrorResponse a simple error response 104 | type ErrorResponse struct { 105 | Message string `json:"message"` 106 | } 107 | 108 | type timestamp struct { 109 | time.Time 110 | } 111 | 112 | // Init start the server 113 | func (s *Server) Init() { 114 | http.Handle("/", corsHandler(http.HandlerFunc(s.route))) 115 | 116 | log.Infof("Listening on %s", s.listenAddress) 117 | log.Fatal(http.ListenAndServe(s.listenAddress, nil)) 118 | } 119 | 120 | func (s *Server) route(w http.ResponseWriter, r *http.Request) { 121 | url := r.URL.EscapedPath() 122 | 123 | query, err := s.fillUserids(w, r) 124 | if err != nil { 125 | return 126 | } 127 | 128 | if url == "/list" { 129 | if s.cfg.IsOptedOut(query.Get("userid")) || s.cfg.IsOptedOut(query.Get("channelid")) { 130 | http.Error(w, "User or channel has opted out", http.StatusForbidden) 131 | return 132 | } 133 | 134 | s.writeAvailableLogs(w, r, query) 135 | return 136 | } 137 | 138 | if url == "/channels" { 139 | s.writeAllChannels(w, r) 140 | return 141 | } 142 | 143 | if url == "/optout" && r.Method == http.MethodPost { 144 | s.writeOptOutCode(w, r) 145 | return 146 | } 147 | 148 | if strings.HasPrefix(url, "/admin/channels") { 149 | success := s.authenticateAdmin(w, r) 150 | if success { 151 | s.writeChannels(w, r) 152 | } 153 | return 154 | } 155 | 156 | routedLogs := s.routeLogs(w, r) 157 | 158 | if !routedLogs { 159 | s.assetsHandler.ServeHTTP(w, r) 160 | return 161 | } 162 | } 163 | 164 | func (s *Server) fillUserids(w http.ResponseWriter, r *http.Request) (url.Values, error) { 165 | query := r.URL.Query() 166 | 167 | if query.Get("userid") == "" && query.Get("user") != "" { 168 | username := strings.ToLower(query.Get("user")) 169 | 170 | users, err := s.helixClient.GetUsersByUsernames([]string{username}) 171 | if err != nil { 172 | http.Error(w, err.Error(), http.StatusInternalServerError) 173 | return nil, err 174 | } 175 | if len(users) == 0 { 176 | err := fmt.Errorf("could not find users") 177 | http.Error(w, err.Error(), http.StatusUnprocessableEntity) 178 | return nil, err 179 | } 180 | 181 | query.Set("userid", users[username].ID) 182 | } 183 | 184 | if query.Get("channelid") == "" && query.Get("channel") != "" { 185 | channelName := strings.ToLower(query.Get("channel")) 186 | 187 | users, err := s.helixClient.GetUsersByUsernames([]string{channelName}) 188 | if err != nil { 189 | http.Error(w, err.Error(), http.StatusInternalServerError) 190 | return nil, err 191 | } 192 | if len(users) == 0 { 193 | err := fmt.Errorf("could not find users") 194 | http.Error(w, err.Error(), http.StatusUnprocessableEntity) 195 | return nil, err 196 | } 197 | 198 | query.Set("channelid", users[channelName].ID) 199 | } 200 | 201 | return query, nil 202 | } 203 | 204 | func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) bool { 205 | 206 | request, err := s.newLogRequestFromURL(r) 207 | if err != nil { 208 | return false 209 | } 210 | if request.redirectPath != "" { 211 | http.Redirect(w, r, request.redirectPath, http.StatusFound) 212 | return true 213 | } 214 | 215 | if s.cfg.IsOptedOut(request.channelid) || s.cfg.IsOptedOut(request.userid) { 216 | http.Error(w, "User or channel has opted out", http.StatusForbidden) 217 | return true 218 | } 219 | 220 | var logs *chatLog 221 | if request.time.random { 222 | if request.isUserRequest { 223 | logs, err = s.getRandomQuote(request) 224 | } else { 225 | logs, err = s.getChannelRandomQuote(request) 226 | } 227 | } else if request.time.from != "" && request.time.to != "" { 228 | if request.isUserRequest { 229 | logs, err = s.getUserLogsRange(request) 230 | } else { 231 | logs, err = s.getChannelLogsRange(request) 232 | } 233 | 234 | } else { 235 | if request.isUserRequest { 236 | logs, err = s.getUserLogs(request) 237 | } else { 238 | logs, err = s.getChannelLogs(request) 239 | } 240 | } 241 | 242 | if err != nil { 243 | log.Error(err) 244 | http.Error(w, "could not load logs", http.StatusNotFound) 245 | return true 246 | } 247 | 248 | // Disable content type sniffing for log output 249 | w.Header().Set("X-Content-Type-Options", "nosniff") 250 | 251 | currentYear := fmt.Sprintf("%d", int(time.Now().Year())) 252 | currentMonth := fmt.Sprintf("%d", int(time.Now().Month())) 253 | 254 | if (request.time.year != "" && request.time.month != "") && (request.time.year < currentYear || (request.time.year == currentYear && request.time.month < currentMonth)) { 255 | writeCacheControl(w, r, time.Hour*8760) 256 | } else { 257 | writeCacheControlNoCache(w, r) 258 | } 259 | 260 | if request.responseType == responseTypeJSON { 261 | writeJSON(logs, http.StatusOK, w, r) 262 | return true 263 | } 264 | 265 | if request.responseType == responseTypeRaw { 266 | writeRaw(logs, http.StatusOK, w, r) 267 | return true 268 | } 269 | 270 | if request.responseType == responseTypeText { 271 | writeText(logs, http.StatusOK, w, r) 272 | return true 273 | } 274 | 275 | return false 276 | } 277 | 278 | func corsHandler(h http.Handler) http.Handler { 279 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 280 | if r.Method == "OPTIONS" { 281 | w.Header().Set("Access-Control-Allow-Origin", "*") 282 | w.Header().Set("Access-Control-Allow-Methods", "GET") 283 | w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") 284 | } else { 285 | w.Header().Set("Access-Control-Allow-Origin", "*") 286 | h.ServeHTTP(w, r) 287 | } 288 | }) 289 | } 290 | 291 | func contains(s []string, e string) bool { 292 | for _, a := range s { 293 | if a == e { 294 | return true 295 | } 296 | } 297 | return false 298 | } 299 | 300 | func reverseSlice(input []string) []string { 301 | for i, j := 0, len(input)-1; i < j; i, j = i+1, j-1 { 302 | input[i], input[j] = input[j], input[i] 303 | } 304 | return input 305 | } 306 | 307 | // swagger:route GET /channels justlog channels 308 | // 309 | // List currently logged channels 310 | // 311 | // Produces: 312 | // - application/json 313 | // - text/plain 314 | // 315 | // Schemes: https 316 | // 317 | // Responses: 318 | // 200: AllChannelsJSON 319 | func (s *Server) writeAllChannels(w http.ResponseWriter, r *http.Request) { 320 | response := new(AllChannelsJSON) 321 | response.Channels = []channel{} 322 | users, err := s.helixClient.GetUsersByUserIds(s.cfg.Channels) 323 | 324 | if err != nil { 325 | log.Error(err) 326 | http.Error(w, "Failure fetching data from twitch", http.StatusInternalServerError) 327 | return 328 | } 329 | 330 | for _, user := range users { 331 | response.Channels = append(response.Channels, channel{UserID: user.ID, Name: user.Login}) 332 | } 333 | 334 | writeJSON(response, http.StatusOK, w, r) 335 | } 336 | 337 | func writeJSON(data interface{}, code int, w http.ResponseWriter, r *http.Request) { 338 | js, err := json.Marshal(data) 339 | if err != nil { 340 | http.Error(w, err.Error(), http.StatusInternalServerError) 341 | return 342 | } 343 | 344 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 345 | w.WriteHeader(code) 346 | w.Write(js) 347 | } 348 | 349 | func writeCacheControl(w http.ResponseWriter, r *http.Request, cacheDuration time.Duration) { 350 | w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", cacheDuration.Seconds())) 351 | } 352 | 353 | func writeCacheControlNoCache(w http.ResponseWriter, r *http.Request) { 354 | w.Header().Set("Cache-Control", "no-cache") 355 | } 356 | 357 | func writeRaw(cLog *chatLog, code int, w http.ResponseWriter, r *http.Request) { 358 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 359 | w.WriteHeader(code) 360 | 361 | for _, cMessage := range cLog.Messages { 362 | w.Write([]byte(cMessage.Raw + "\n")) 363 | } 364 | } 365 | 366 | func writeText(cLog *chatLog, code int, w http.ResponseWriter, r *http.Request) { 367 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 368 | w.WriteHeader(code) 369 | 370 | for _, cMessage := range cLog.Messages { 371 | switch cMessage.Type { 372 | case twitch.PRIVMSG: 373 | 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))) 374 | case twitch.CLEARCHAT: 375 | w.Write([]byte(fmt.Sprintf("[%s] #%s %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Text))) 376 | case twitch.USERNOTICE: 377 | w.Write([]byte(fmt.Sprintf("[%s] #%s %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Text))) 378 | } 379 | } 380 | } 381 | 382 | func (t timestamp) MarshalJSON() ([]byte, error) { 383 | return []byte("\"" + t.UTC().Format(time.RFC3339) + "\""), nil 384 | } 385 | 386 | func (t *timestamp) UnmarshalJSON(data []byte) error { 387 | goTime, err := time.Parse(time.RFC3339, strings.TrimSuffix(strings.TrimPrefix(string(data[:]), "\""), "\"")) 388 | if err != nil { 389 | return err 390 | } 391 | *t = timestamp{ 392 | goTime, 393 | } 394 | return nil 395 | } 396 | 397 | func createLogResult() chatLog { 398 | return chatLog{Messages: []chatMessage{}} 399 | } 400 | 401 | func parseFromTo(from, to string, limit float64) (time.Time, time.Time, error) { 402 | var fromTime time.Time 403 | var toTime time.Time 404 | 405 | if from == "" && to == "" { 406 | fromTime = time.Now().AddDate(0, -1, 0) 407 | toTime = time.Now() 408 | } else if from == "" && to != "" { 409 | var err error 410 | toTime, err = parseTimestamp(to) 411 | if err != nil { 412 | return fromTime, toTime, fmt.Errorf("Can't parse to timestamp: %s", err) 413 | } 414 | fromTime = toTime.AddDate(0, -1, 0) 415 | } else if from != "" && to == "" { 416 | var err error 417 | fromTime, err = parseTimestamp(from) 418 | if err != nil { 419 | return fromTime, toTime, fmt.Errorf("Can't parse from timestamp: %s", err) 420 | } 421 | toTime = fromTime.AddDate(0, 1, 0) 422 | } else { 423 | var err error 424 | 425 | fromTime, err = parseTimestamp(from) 426 | if err != nil { 427 | return fromTime, toTime, fmt.Errorf("Can't parse from timestamp: %s", err) 428 | } 429 | toTime, err = parseTimestamp(to) 430 | if err != nil { 431 | return fromTime, toTime, fmt.Errorf("Can't parse to timestamp: %s", err) 432 | } 433 | 434 | if toTime.Sub(fromTime).Hours() > limit { 435 | return fromTime, toTime, errors.New("Timespan too big") 436 | } 437 | } 438 | 439 | return fromTime, toTime, nil 440 | } 441 | 442 | func createChatMessage(parsedMessage twitch.Message) chatMessage { 443 | switch message := parsedMessage.(type) { 444 | case *twitch.PrivateMessage: 445 | return chatMessage{ 446 | Timestamp: timestamp{message.Time}, 447 | Username: message.User.Name, 448 | DisplayName: message.User.DisplayName, 449 | Text: message.Message, 450 | Type: message.Type, 451 | Channel: message.Channel, 452 | Raw: message.Raw, 453 | ID: message.ID, 454 | Tags: message.Tags, 455 | } 456 | case *twitch.ClearChatMessage: 457 | return chatMessage{ 458 | Timestamp: timestamp{message.Time}, 459 | Username: message.TargetUsername, 460 | DisplayName: message.TargetUsername, 461 | SystemText: buildClearChatMessageText(*message), 462 | Type: message.Type, 463 | Channel: message.Channel, 464 | Raw: message.Raw, 465 | Tags: message.Tags, 466 | } 467 | case *twitch.UserNoticeMessage: 468 | return chatMessage{ 469 | Timestamp: timestamp{message.Time}, 470 | Username: message.User.Name, 471 | DisplayName: message.User.DisplayName, 472 | Text: message.Message, 473 | SystemText: message.SystemMsg, 474 | Type: message.Type, 475 | Channel: message.Channel, 476 | Raw: message.Raw, 477 | ID: message.ID, 478 | Tags: message.Tags, 479 | } 480 | } 481 | 482 | return chatMessage{} 483 | } 484 | 485 | func parseTimestamp(timestamp string) (time.Time, error) { 486 | 487 | i, err := strconv.ParseInt(timestamp, 10, 64) 488 | if err != nil { 489 | return time.Now(), err 490 | } 491 | return time.Unix(i, 0), nil 492 | } 493 | 494 | func buildClearChatMessageText(message twitch.ClearChatMessage) string { 495 | if message.BanDuration == 0 { 496 | return fmt.Sprintf("%s has been banned", message.TargetUsername) 497 | } 498 | 499 | return fmt.Sprintf("%s has been timed out for %d seconds", message.TargetUsername, message.BanDuration) 500 | } 501 | --------------------------------------------------------------------------------