commit
478409cf70
15 changed files with 676 additions and 0 deletions
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
--- |
||||
kind: pipeline |
||||
name: compliance |
||||
trigger: |
||||
event: |
||||
- pull_request |
||||
steps: |
||||
- name: build |
||||
pull: always |
||||
image: golang:1.16 |
||||
commands: |
||||
- make test |
||||
- make build |
||||
- name: check |
||||
pull: always |
||||
image: golang:1.16 |
||||
commands: |
||||
- make vet |
||||
|
||||
--- |
||||
kind: pipeline |
||||
name: release |
||||
trigger: |
||||
event: |
||||
- push |
||||
branch: |
||||
- master |
||||
steps: |
||||
- name: build |
||||
pull: always |
||||
image: golang:1.16 |
||||
commands: |
||||
- make build |
||||
- name: gitea-release |
||||
pull: always |
||||
image: jolheiser/drone-gitea-main:latest |
||||
settings: |
||||
token: |
||||
from_secret: gitea_token |
||||
base: https://gitea.com |
||||
files: |
||||
- "lurk" |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
# GoLand |
||||
.idea/ |
||||
|
||||
# Binaries |
||||
/lurk* |
||||
|
||||
!lurk.sample.toml |
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2021 John Olheiser |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
.PHONY: build |
||||
build: |
||||
go build |
||||
|
||||
.PHONY: fmt |
||||
fmt: |
||||
go fmt ./... |
||||
|
||||
.PHONY: test |
||||
test: |
||||
go test -race ./... |
||||
|
||||
.PHONY: vet |
||||
vet: |
||||
go vet ./... |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
# lurk |
||||
|
||||
A lurker used to notify a Discord channel via webhook |
||||
whenever a matching submission is made. |
||||
|
||||
See the [example config](lurk.sample.toml). |
@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
|
||||
package config |
||||
|
||||
import ( |
||||
"regexp" |
||||
"strings" |
||||
|
||||
"github.com/pelletier/go-toml" |
||||
"go.jolheiser.com/beaver" |
||||
) |
||||
|
||||
type Config struct { |
||||
Reddit RedditConfig `toml:"reddit"` |
||||
Twitter TwitterConfig `toml:"twitter"` |
||||
} |
||||
|
||||
func (c *Config) loadReddit() { |
||||
for _, sub := range c.Reddit.SubReddits { |
||||
c.Reddit.Map[strings.ToLower(sub.Name)] = sub |
||||
if sub.TitleLimit == 0 || sub.TitleLimit > 253 { |
||||
sub.TitleLimit = 253 |
||||
} |
||||
if sub.BodyLimit == 0 || sub.BodyLimit > 2045 { |
||||
sub.BodyLimit = 2045 |
||||
} |
||||
sub.FlairAllowlistRe = make([]*regexp.Regexp, len(sub.FlairAllowlist)) |
||||
for idx, f := range sub.FlairAllowlist { |
||||
sub.FlairAllowlistRe[idx] = regexp.MustCompile(f) |
||||
} |
||||
sub.FlairBlocklistRe = make([]*regexp.Regexp, len(sub.FlairBlocklist)) |
||||
for idx, f := range sub.FlairBlocklist { |
||||
sub.FlairBlocklistRe[idx] = regexp.MustCompile(f) |
||||
} |
||||
sub.TitleAllowlistRe = make([]*regexp.Regexp, len(sub.TitleAllowlist)) |
||||
for idx, t := range sub.TitleAllowlist { |
||||
sub.TitleAllowlistRe[idx] = regexp.MustCompile(t) |
||||
} |
||||
sub.TitleBlocklistRe = make([]*regexp.Regexp, len(sub.TitleBlocklist)) |
||||
for idx, t := range sub.TitleBlocklist { |
||||
sub.TitleBlocklistRe[idx] = regexp.MustCompile(t) |
||||
} |
||||
sub.BodyAllowlistRe = make([]*regexp.Regexp, len(sub.BodyAllowlist)) |
||||
for idx, b := range sub.BodyAllowlist { |
||||
sub.BodyAllowlistRe[idx] = regexp.MustCompile(b) |
||||
} |
||||
sub.BodyBlocklistRe = make([]*regexp.Regexp, len(sub.BodyBlocklist)) |
||||
for idx, b := range sub.BodyBlocklist { |
||||
sub.BodyBlocklistRe[idx] = regexp.MustCompile(b) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func Load(configPath string) (*Config, error) { |
||||
cfg := Config{ |
||||
Reddit: RedditConfig{ |
||||
Map: make(map[string]*SubReddit), |
||||
}, |
||||
} |
||||
|
||||
tree, err := toml.LoadFile(configPath) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := tree.Unmarshal(&cfg); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
cfg.loadReddit() |
||||
beaver.Debug(cfg) |
||||
return &cfg, nil |
||||
} |
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
package config |
||||
|
||||
import ( |
||||
"fmt" |
||||
"regexp" |
||||
) |
||||
|
||||
type RedditConfig struct { |
||||
SubReddits []*SubReddit `toml:"sub"` |
||||
Map map[string]*SubReddit `toml:"-"` |
||||
|
||||
// Agent file
|
||||
AppName string `toml:"app_name"` |
||||
Version string `toml:"version"` |
||||
|
||||
ClientID string `toml:"client_id"` |
||||
ClientSecret string `toml:"client_secret"` |
||||
|
||||
Username string `toml:"username"` |
||||
Password string `toml:"password"` |
||||
} |
||||
|
||||
type SubReddit struct { |
||||
Name string `toml:"name"` |
||||
IconURL string `toml:"icon_url"` |
||||
FlairAllowlist []string `toml:"flair_allowlist"` |
||||
FlairAllowlistRe []*regexp.Regexp `toml:"-"` |
||||
FlairBlocklist []string `toml:"flair_blocklist"` |
||||
FlairBlocklistRe []*regexp.Regexp `toml:"-"` |
||||
TitleAllowlist []string `toml:"title_allowlist"` |
||||
TitleAllowlistRe []*regexp.Regexp `toml:"-"` |
||||
TitleBlocklist []string `toml:"title_blocklist"` |
||||
TitleBlocklistRe []*regexp.Regexp `toml:"-"` |
||||
TitleLimit int `toml:"title_limit"` |
||||
BodyAllowlist []string `toml:"body_allowlist"` |
||||
BodyAllowlistRe []*regexp.Regexp `toml:"-"` |
||||
BodyBlocklist []string `toml:"body_blocklist"` |
||||
BodyBlocklistRe []*regexp.Regexp `toml:"-"` |
||||
BodyLimit int `toml:"body_limit"` |
||||
Webhook string `toml:"webhook"` |
||||
} |
||||
|
||||
func (r *RedditConfig) UserAgent() string { |
||||
return fmt.Sprintf("%s/%s by /u/%s", r.AppName, r.Version, r.Username) |
||||
} |
||||
|
||||
func (r *RedditConfig) SubRedditNames() []string { |
||||
names := make([]string, len(r.SubReddits)) |
||||
for idx, sub := range r.SubReddits { |
||||
names[idx] = sub.Name |
||||
} |
||||
return names |
||||
} |
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
package config |
||||
|
||||
type TwitterConfig struct { |
||||
ConsumerKey string `toml:"consumer_key"` |
||||
ConsumerSecret string `toml:"consumer_secret"` |
||||
AccessToken string `toml:"access_token"` |
||||
AccessSecret string `toml:"access_secret"` |
||||
|
||||
Filters []Filter `toml:"filter"` |
||||
} |
||||
|
||||
type Filter struct { |
||||
Follows []string `toml:"follows"` |
||||
FollowStrict bool `toml:"follow_strict"` |
||||
Locations []string `toml:"locations"` |
||||
Tracks []string `toml:"tracks"` |
||||
Webhook string `toml:"webhook"` |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
module go.jolheiser.com/lurk |
||||
|
||||
go 1.16 |
||||
|
||||
require ( |
||||
github.com/dghubble/go-twitter v0.0.0-20200725221434-4bc8ad7ad1b4 |
||||
github.com/dghubble/oauth1 v0.6.0 |
||||
github.com/pelletier/go-toml v1.8.1 |
||||
github.com/turnage/graw v0.0.0-20200404033202-65715eea1cd0 |
||||
go.jolheiser.com/beaver v1.0.2 |
||||
go.jolheiser.com/disco v0.0.2 |
||||
) |
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= |
||||
github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= |
||||
github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= |
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
||||
github.com/dghubble/go-twitter v0.0.0-20200725221434-4bc8ad7ad1b4 h1:I60CX3+rWlBGPXR13jwxxBWB2GhlhZkEEh5o1eDLzJg= |
||||
github.com/dghubble/go-twitter v0.0.0-20200725221434-4bc8ad7ad1b4/go.mod h1:xfg4uS5LEzOj8PgZV7SQYRHbG7jPUnelEiaAVJxmhJE= |
||||
github.com/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0= |
||||
github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= |
||||
github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= |
||||
github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= |
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= |
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= |
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= |
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= |
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= |
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= |
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= |
||||
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= |
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= |
||||
github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= |
||||
github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= |
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= |
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
||||
github.com/turnage/graw v0.0.0-20200404033202-65715eea1cd0 h1:ss0FREpyYvw5V9t5XWWWIvUJnRgWFzdftvCXd8WQNhU= |
||||
github.com/turnage/graw v0.0.0-20200404033202-65715eea1cd0/go.mod h1:aAkq4I/q1izZSSwHvzhDn9NA+eGxgTSuibwP3MZRlQY= |
||||
github.com/turnage/redditproto v0.0.0-20151223012412-afedf1b6eddb h1:qR56NGRvs2hTUbkn6QF8bEJzxPIoMw3Np3UigBeJO5A= |
||||
github.com/turnage/redditproto v0.0.0-20151223012412-afedf1b6eddb/go.mod h1:GyqJdEoZSNoxKDb7Z2Lu/bX63jtFukwpaTP9ZIS5Ei0= |
||||
go.jolheiser.com/beaver v1.0.2 h1:KA2D6iO8MQhZi1nZYi/Chak/f1Cxfrs6b1XO623+Khk= |
||||
go.jolheiser.com/beaver v1.0.2/go.mod h1:7X4F5+XOGSC3LejTShoBdqtRCnPWcnRgmYGmG3EKW8g= |
||||
go.jolheiser.com/disco v0.0.2 h1:UGYNqO7NQSBGB/OoS9WE5o/jYvmx1G0Bq3qQRM42Bkw= |
||||
go.jolheiser.com/disco v0.0.2/go.mod h1:tY3HkJmMrzXH/bPgDWKHn1DUzDxkemD80OHLgHSA5uQ= |
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= |
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= |
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= |
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 h1:pE8b58s1HRDMi8RDc79m0HISf9D4TzseP40cEA6IGfs= |
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= |
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= |
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= |
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= |
@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
|
||||
package handler |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"net/http" |
||||
"regexp" |
||||
"strings" |
||||
"time" |
||||
|
||||
"go.jolheiser.com/lurk/config" |
||||
|
||||
"github.com/turnage/graw/reddit" |
||||
"go.jolheiser.com/beaver" |
||||
"go.jolheiser.com/disco" |
||||
) |
||||
|
||||
var httpClient = &http.Client{Timeout: time.Minute} |
||||
|
||||
type Reddit struct { |
||||
Config *config.Config |
||||
} |
||||
|
||||
func (r *Reddit) Post(p *reddit.Post) error { |
||||
sub := r.Config.Reddit.Map[strings.ToLower(p.Subreddit)] |
||||
|
||||
if err := checkPost(r.Config, p); err != nil { |
||||
beaver.Debugf("%s: %v", p.Subreddit, err) |
||||
return nil |
||||
} |
||||
|
||||
title := p.Title |
||||
if len(title) > sub.TitleLimit { |
||||
title = title[:sub.TitleLimit] + "..." |
||||
} |
||||
description := p.SelfText |
||||
if len(description) > sub.BodyLimit { |
||||
description = description[:sub.BodyLimit] + "..." |
||||
} |
||||
|
||||
e := disco.Webhook{ |
||||
Username: "/r/" + p.Subreddit, |
||||
AvatarURL: sub.IconURL, |
||||
Embeds: []*disco.Embed{ |
||||
{ |
||||
Title: title, |
||||
URL: p.URL, |
||||
Description: description, |
||||
Color: 0x007D96, |
||||
Timestamp: disco.Now(), |
||||
Author: &disco.Author{ |
||||
Name: "/u/" + p.Author, |
||||
URL: fmt.Sprintf("https://reddit.com/user/%s", p.Author), |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
if sub.Webhook == "" { |
||||
beaver.Errorf("no webhook for %s", p.Subreddit) |
||||
return nil |
||||
} |
||||
|
||||
req, err := e.Request(context.Background(), sub.Webhook) |
||||
if err != nil { |
||||
beaver.Error(err) |
||||
return nil |
||||
} |
||||
|
||||
resp, err := httpClient.Do(req) |
||||
if err != nil { |
||||
beaver.Error(err) |
||||
return nil |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusNoContent { |
||||
beaver.Error(resp.Status) |
||||
return nil |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func checkPost(c *config.Config, p *reddit.Post) error { |
||||
sub := c.Reddit.Map[strings.ToLower(p.Subreddit)] |
||||
|
||||
// Check blocklist first
|
||||
// Any match means we ignore
|
||||
if matchesAny(p.LinkFlairText, sub.FlairBlocklistRe) { |
||||
return fmt.Errorf("flair matched blocklisted regex: %s", p.LinkFlairText) |
||||
} |
||||
if matchesAny(p.Title, sub.TitleBlocklistRe) { |
||||
return fmt.Errorf("title matched blocklisted regex: %s", p.Title) |
||||
} |
||||
if matchesAny(p.SelfText, sub.BodyBlocklistRe) { |
||||
return fmt.Errorf("body matched blocklisted regex: %s", p.SelfText) |
||||
} |
||||
|
||||
// Check allowlist
|
||||
// Any match means we pass
|
||||
// If no allowlist, pass
|
||||
if len(sub.FlairAllowlistRe) > 0 && !matchesAny(p.LinkFlairText, sub.FlairAllowlistRe) { |
||||
return fmt.Errorf("flair didn't match any allowlisted regex: %s", p.LinkFlairText) |
||||
} |
||||
if len(sub.TitleAllowlistRe) > 0 && !matchesAny(p.Title, sub.TitleAllowlistRe) { |
||||
return fmt.Errorf("title didn't match any allowlisted regex: %s", p.Title) |
||||
} |
||||
if len(sub.BodyAllowlistRe) > 0 && !matchesAny(p.SelfText, sub.BodyAllowlistRe) { |
||||
return fmt.Errorf("body didn't match any allowlisted regex: %s", p.SelfText) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func matchesAny(input string, re []*regexp.Regexp) bool { |
||||
for _, r := range re { |
||||
if r.MatchString(input) { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
|
||||
package handler |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
"go.jolheiser.com/lurk/config" |
||||
|
||||
"github.com/dghubble/go-twitter/twitter" |
||||
"go.jolheiser.com/beaver" |
||||
"go.jolheiser.com/disco" |
||||
) |
||||
|
||||
type Twitter struct { |
||||
Filter config.Filter |
||||
Stream *twitter.Stream |
||||
} |
||||
|
||||
func (t *Twitter) Run() { |
||||
beaver.Debugf("setting up stream for %v", t.Filter) |
||||
demux := twitter.NewSwitchDemux() |
||||
demux.Tweet = t.Tweet |
||||
|
||||
beaver.Debugf("streaming %v", t.Filter) |
||||
demux.HandleChan(t.Stream.Messages) |
||||
beaver.Debugf("disconnected from stream: %v", t.Filter) |
||||
} |
||||
|
||||
func (t *Twitter) Tweet(tweet *twitter.Tweet) { |
||||
beaver.Debugf("new tweet for %v", t.Filter) |
||||
|
||||
if t.Filter.FollowStrict { |
||||
if tweet.InReplyToStatusIDStr != "" { |
||||
beaver.Debug("tweet is a reply") |
||||
return |
||||
} |
||||
var match bool |
||||
for _, id := range t.Filter.Follows { |
||||
if id == tweet.User.IDStr { |
||||
match = true |
||||
break |
||||
} |
||||
} |
||||
if !match { |
||||
beaver.Debug("tweet did not match any follow IDs") |
||||
return |
||||
} |
||||
} |
||||
|
||||
w := &disco.Webhook{ |
||||
Username: tweet.User.Name, |
||||
Content: fmt.Sprintf("https://twitter.com/%d/status/%d", tweet.User.ID, tweet.ID), |
||||
} |
||||
if _, err := w.Send(context.Background(), t.Filter.Webhook); err != nil { |
||||
beaver.Error(err) |
||||
} |
||||
} |
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
# Reddit information |
||||
[reddit] |
||||
# Reddit Agent Info |
||||
app_name = "Lurk" |
||||
version = "0.1" |
||||
|
||||
# https://www.reddit.com/prefs/apps |
||||
client_id = "" |
||||
client_secret = "" |
||||
|
||||
# Reddit credentials |
||||
username = "" |
||||
password = "" |
||||
|
||||
# A list of subreddites to monitor |
||||
# allow/blocklist can be omitted in real config if empty |
||||
[[reddit.sub]] |
||||
name = "" |
||||
icon_url = "" |
||||
flair_allowlist = [] |
||||
flair_blocklist = [] |
||||
title_allowlist = [] |
||||
title_blocklist = [] |
||||
title_limit = 0 |
||||
body_allowlist = [] |
||||
body_blocklist = [] |
||||
body_limit = 0 |
||||
webhook = "" |
||||
|
||||
# Twitter information |
||||
[twitter] |
||||
# Auth |
||||
consumer_key = "" |
||||
consumer_secret = "" |
||||
access_token = "" |
||||
access_secret = "" |
||||
|
||||
# A list of filters to watch for |
||||
# Empty fields can be omitted in a real config |
||||
[[twitter.filter]] |
||||
# follows must use a Twitter user's ID |
||||
# https://tweeterid.com/ |
||||
follows = [] |
||||
# strict mode means only original tweets will be ingested |
||||
follow_strict = false |
||||
locations = [] |
||||
tracks = [] |
||||
webhook = "" |
@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"flag" |
||||
"os" |
||||
"os/signal" |
||||
"syscall" |
||||
|
||||
"go.jolheiser.com/lurk/config" |
||||
"go.jolheiser.com/lurk/handler" |
||||
|
||||
"github.com/dghubble/go-twitter/twitter" |
||||
"github.com/dghubble/oauth1" |
||||
"github.com/turnage/graw" |
||||
"github.com/turnage/graw/reddit" |
||||
"go.jolheiser.com/beaver" |
||||
) |
||||
|
||||
var ( |
||||
configPath string |
||||
debug bool |
||||
) |
||||
|
||||
func main() { |
||||
flag.StringVar(&configPath, "config", "lurk.toml", "Path to lurk's config file") |
||||
flag.BoolVar(&debug, "debug", false, "Turn on debug mode") |
||||
flag.Parse() |
||||
|
||||
beaver.Console.Format = beaver.FormatOptions{ |
||||
TimePrefix: true, |
||||
StackPrefix: true, |
||||
StackLimit: 15, |
||||
LevelPrefix: true, |
||||
LevelColor: true, |
||||
} |
||||
if debug { |
||||
beaver.Console.Level = beaver.DEBUG |
||||
} |
||||
|
||||
cfg, err := config.Load(configPath) |
||||
if err != nil { |
||||
beaver.Fatal(err) |
||||
} |
||||
|
||||
// Reddit
|
||||
go lurkReddit(cfg) |
||||
|
||||
// Twitter
|
||||
go lurkTwitter(cfg) |
||||
|
||||
beaver.Info("Lurk is ready to start lurking!") |
||||
ch := make(chan os.Signal) |
||||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) |
||||
<-ch |
||||
} |
||||
|
||||
func lurkReddit(cfg *config.Config) { |
||||
bot, err := reddit.NewBot(reddit.BotConfig{ |
||||
Agent: cfg.Reddit.UserAgent(), |
||||
App: reddit.App{ |
||||
ID: cfg.Reddit.ClientID, |
||||
Secret: cfg.Reddit.ClientSecret, |
||||
Username: cfg.Reddit.Username, |
||||
Password: cfg.Reddit.Password, |
||||
}, |
||||
}) |
||||
if err != nil { |
||||
beaver.Fatal(err) |
||||
} |
||||
|
||||
_, wait, err := graw.Run(&handler.Reddit{ |
||||
Config: cfg, |
||||
}, bot, graw.Config{ |
||||
Subreddits: cfg.Reddit.SubRedditNames(), |
||||
}) |
||||
if err != nil { |
||||
beaver.Errorf("could not run reddit bot: %v", err) |
||||
return |
||||
} |
||||
if err := wait(); err != nil { |
||||
beaver.Fatal(err) |
||||
} |
||||
} |
||||
|
||||
func lurkTwitter(cfg *config.Config) { |
||||
twitterConfig := oauth1.NewConfig(cfg.Twitter.ConsumerKey, cfg.Twitter.ConsumerSecret) |
||||
token := oauth1.NewToken(cfg.Twitter.AccessToken, cfg.Twitter.AccessSecret) |
||||
|
||||
httpClient := twitterConfig.Client(oauth1.NoContext, token) |
||||
client := twitter.NewClient(httpClient) |
||||
|
||||
// Just to test if we have valid auth
|
||||
_, _, err := client.Timelines.HomeTimeline(&twitter.HomeTimelineParams{ |
||||
Count: 1, |
||||
}) |
||||
if err != nil { |
||||
beaver.Fatal(err) |
||||
} |
||||
|
||||
for _, filter := range cfg.Twitter.Filters { |
||||
stream, err := client.Streams.Filter(&twitter.StreamFilterParams{ |
||||
Follow: filter.Follows, |
||||
Locations: filter.Locations, |
||||
StallWarnings: twitter.Bool(false), |
||||
Track: filter.Tracks, |
||||
}) |
||||
if err != nil { |
||||
beaver.Fatal(err) |
||||
} |
||||
|
||||
lurker := &handler.Twitter{ |
||||
Filter: filter, |
||||
Stream: stream, |
||||
} |
||||
|
||||
go lurker.Run() |
||||
} |
||||
} |
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"os" |
||||
"regexp" |
||||
"testing" |
||||
) |
||||
|
||||
func TestMain(m *testing.M) { |
||||
os.Exit(m.Run()) |
||||
} |
||||
|
||||
func TestMatchesAny(t *testing.T) { |
||||
tt := []struct { |
||||
Regex string |
||||
Input string |
||||
Match bool |
||||
}{ |
||||
{Regex: `JAVA`, Input: "java", Match: false}, |
||||
{Regex: `(?i)JAVA`, Input: "java", Match: true}, |
||||
{Regex: `white`, Input: "whitelist", Match: true}, |
||||
{Regex: `\bwhite\b`, Input: "whitelist", Match: false}, |
||||
{Regex: `\bwhite\b`, Input: "white list", Match: true}, |
||||
{Regex: `\bwhite\b`, Input: "a white list", Match: true}, |
||||
{Regex: `\swhite\s`, Input: "whitelist", Match: false}, |
||||
{Regex: `\swhite\s`, Input: "white list", Match: false}, |
||||
{Regex: `\swhite\s`, Input: "a white list", Match: true}, |
||||
} |
||||
|
||||
for _, tc := range tt { |
||||
t.Run(tc.Regex, func(t *testing.T) { |
||||
r := regexp.MustCompile(tc.Regex) |
||||
if r.MatchString(tc.Input) != tc.Match { |
||||
t.Fail() |
||||
} |
||||
}) |
||||
} |
||||
} |
Loading…
Reference in new issue