Gisle Aune
6 years ago
commit
0ecbc16bdb
15 changed files with 686 additions and 0 deletions
-
BINdebug
-
125internal/api/client.go
-
35internal/api/error.go
-
122internal/bot/bot.go
-
34internal/bot/handler.go
-
53internal/config/config.go
-
49internal/models/change.go
-
9internal/models/channel.go
-
40internal/models/channels/find.go
-
40internal/models/channels/list-logged.go
-
40internal/models/channels/set-logged.go
-
30internal/models/channels/subscribe-logged.go
-
26internal/models/user.go
-
42internal/models/users/check-token.go
-
41main.go
@ -0,0 +1,125 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"bytes" |
|||
"context" |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"io/ioutil" |
|||
"net/http" |
|||
"time" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/config" |
|||
jwt "github.com/dgrijalva/jwt-go" |
|||
) |
|||
|
|||
type queryData struct { |
|||
Query string `json:"query"` |
|||
Variables map[string]interface{} `json:"variables"` |
|||
} |
|||
|
|||
type queryResponse struct { |
|||
Errors QueryErrorList `json:"errors"` |
|||
Data json.RawMessage `json:"data"` |
|||
} |
|||
|
|||
// A Client is a client for the rpdata API
|
|||
type Client struct { |
|||
http *http.Client |
|||
endpoint string |
|||
username string |
|||
keyID string |
|||
keySecret string |
|||
} |
|||
|
|||
func (client *Client) sign(permissions []string) (token string, err error) { |
|||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ |
|||
"kid": "bar", |
|||
"user": client.username, |
|||
"permissions": permissions, |
|||
"exp": time.Now().Add(time.Second * 30).Unix(), |
|||
}) |
|||
jwtToken.Header["kid"] = client.keyID |
|||
|
|||
return jwtToken.SignedString([]byte(client.keySecret)) |
|||
} |
|||
|
|||
// Query sends a query or mutation to the server. The returned value is the data element.
|
|||
func (client *Client) Query(ctx context.Context, query string, variables map[string]interface{}, permissions []string) (data json.RawMessage, err error) { |
|||
// Get token
|
|||
token, err := client.sign(permissions) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// Make body
|
|||
qdata := queryData{ |
|||
Query: query, |
|||
Variables: variables, |
|||
} |
|||
buffer := bytes.NewBuffer(make([]byte, 0, 256)) |
|||
err = json.NewEncoder(buffer).Encode(&qdata) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// Make request
|
|||
req, err := http.NewRequest("POST", client.endpoint, buffer) |
|||
if err != nil { |
|||
return |
|||
} |
|||
req = req.WithContext(ctx) |
|||
req.Header.Set("Content-Type", "application/json") |
|||
req.Header.Set("Authorization", "Bearer "+token) |
|||
|
|||
// Run request
|
|||
res, err := client.http.Do(req) |
|||
if err != nil { |
|||
return |
|||
} |
|||
defer res.Body.Close() |
|||
|
|||
if res.StatusCode >= 500 { |
|||
stuff, _ := ioutil.ReadAll(res.Body) |
|||
fmt.Println(string(stuff)) |
|||
return nil, errors.New("Internal srever error") |
|||
} |
|||
|
|||
// Parse response
|
|||
resData := queryResponse{} |
|||
err = json.NewDecoder(res.Body).Decode(&resData) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
if len(resData.Errors) > 0 { |
|||
err = resData.Errors |
|||
return |
|||
} |
|||
|
|||
return resData.Data, nil |
|||
} |
|||
|
|||
// New makes a new client.
|
|||
func New(endpoint, username, keyID, keySecret string) *Client { |
|||
return &Client{ |
|||
http: &http.Client{Timeout: 60 * time.Second}, |
|||
endpoint: endpoint, |
|||
username: username, |
|||
keyID: keyID, |
|||
keySecret: keySecret, |
|||
} |
|||
} |
|||
|
|||
// Global gets the global client, from the config file.
|
|||
func Global() *Client { |
|||
conf := config.Get() |
|||
return &Client{ |
|||
http: &http.Client{Timeout: 60 * time.Second}, |
|||
endpoint: conf.API.Endpoint, |
|||
username: conf.API.Username, |
|||
keyID: conf.API.Key.ID, |
|||
keySecret: conf.API.Key.Secret, |
|||
} |
|||
} |
@ -0,0 +1,35 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
) |
|||
|
|||
// A QueryError is a GraphQL error.
|
|||
type QueryError struct { |
|||
Message string `json:"message"` |
|||
Path []string `json:"path"` |
|||
} |
|||
|
|||
func (err QueryError) Error() string { |
|||
return fmt.Sprintf("GraphQL: %s: %s", strings.Join(err.Path, "."), err.Message) |
|||
} |
|||
|
|||
func (err QueryError) String() string { |
|||
return err.Error() |
|||
} |
|||
|
|||
// QueryErrorList is a list of query errors
|
|||
type QueryErrorList []QueryError |
|||
|
|||
func (errs QueryErrorList) Error() string { |
|||
if len(errs) == 1 { |
|||
return errs[0].Error() |
|||
} |
|||
|
|||
return fmt.Sprintf("%s (+%d others)", errs[0].Error(), len(errs)-1) |
|||
} |
|||
|
|||
func (errs QueryErrorList) String() string { |
|||
return errs.Error() |
|||
} |
@ -0,0 +1,122 @@ |
|||
package bot |
|||
|
|||
import ( |
|||
"context" |
|||
"log" |
|||
"strings" |
|||
"time" |
|||
|
|||
"git.aiterp.net/gisle/irc" |
|||
"git.aiterp.net/rpdata/logbot3/internal/models/channels" |
|||
) |
|||
|
|||
var botKey = "git.aiterp.net/rpdata/logbot.Bot.key" |
|||
|
|||
// The Bot is the IRC client.
|
|||
type Bot struct { |
|||
commandChannel string |
|||
client *irc.Client |
|||
ctx context.Context |
|||
ctxCancel context.CancelFunc |
|||
} |
|||
|
|||
// New creates a new Bot.
|
|||
func New(ctx context.Context, nick string, alternatives []string) *Bot { |
|||
client := irc.New(ctx, irc.Config{ |
|||
Nick: nick, |
|||
Alternatives: alternatives, |
|||
SendRate: 2, |
|||
SkipSSLVerification: false, |
|||
}) |
|||
|
|||
bot := &Bot{ |
|||
client: client, |
|||
} |
|||
|
|||
client.SetValue(botKey, bot) |
|||
|
|||
return bot |
|||
} |
|||
|
|||
// Connect connects the bot to the IRC server. This will disconnect already
|
|||
// established connections.
|
|||
func (bot *Bot) Connect(server string, ssl bool) error { |
|||
if bot.ctxCancel != nil { |
|||
bot.ctxCancel() |
|||
} |
|||
bot.ctx, bot.ctxCancel = context.WithCancel(bot.client.Context()) |
|||
|
|||
return bot.client.Connect(server, ssl) |
|||
} |
|||
|
|||
func (bot *Bot) loop() { |
|||
ticker := time.NewTicker(time.Second * 10) |
|||
defer ticker.Stop() |
|||
|
|||
log.Println("Client ready.") |
|||
|
|||
for { |
|||
select { |
|||
case <-ticker.C: |
|||
{ |
|||
bot.syncChannels() |
|||
} |
|||
|
|||
case <-bot.ctx.Done(): |
|||
{ |
|||
log.Println("Spinning down bot main loop.") |
|||
return |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
func (bot *Bot) syncChannels() { |
|||
channels, err := channels.ListOpen(bot.ctx) |
|||
if err != nil { |
|||
log.Println("Failed to update channel-list:", err) |
|||
return |
|||
} |
|||
|
|||
names := make([]string, 0, len(channels)) |
|||
joins := make([]string, 0, len(channels)) |
|||
leaves := make([]string, 0, len(names)) |
|||
|
|||
// Add new channels to join list.
|
|||
for _, channel := range channels { |
|||
name := channel.Name |
|||
names = append(names, name) |
|||
|
|||
if bot.client.Channel(name) == nil { |
|||
joins = append(joins, name) |
|||
} |
|||
} |
|||
|
|||
// Add no longer logged channels to leave list.
|
|||
LeaveLoop: |
|||
for _, channel := range bot.client.Channels() { |
|||
for _, name := range names { |
|||
if strings.ToLower(channel.Name()) == strings.ToLower(name) { |
|||
continue LeaveLoop |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Join channels.
|
|||
if len(joins) > 0 { |
|||
log.Println("Joining", strings.Join(joins, ", ")) |
|||
bot.client.SendQueuedf("JOIN %s", strings.Join(joins, ",")) |
|||
} |
|||
|
|||
// leave channels.
|
|||
if len(leaves) > 0 { |
|||
log.Println("Leaving", strings.Join(leaves, ", ")) |
|||
bot.client.SendQueuedf("PART %s :Channel removed.", strings.Join(leaves, ",")) |
|||
} |
|||
} |
|||
|
|||
func (bot *Bot) stopLoop() { |
|||
if bot.ctxCancel != nil { |
|||
bot.ctxCancel() |
|||
} |
|||
} |
@ -0,0 +1,34 @@ |
|||
package bot |
|||
|
|||
import ( |
|||
"log" |
|||
"strings" |
|||
|
|||
"git.aiterp.net/gisle/irc" |
|||
) |
|||
|
|||
func handler(event *irc.Event, client *irc.Client) { |
|||
bot, ok := client.Value(botKey).(*Bot) |
|||
if !ok { |
|||
return |
|||
} |
|||
|
|||
if event.Kind() == "packet" && len(event.Verb()) == 3 { |
|||
log.Printf("(%s) %s %s\n", event.Verb(), strings.Join(event.Args[1:], " "), event.Text) |
|||
} |
|||
|
|||
switch event.Name() { |
|||
case "hook.ready": |
|||
{ |
|||
go bot.loop() |
|||
} |
|||
case "client.disconnect": |
|||
{ |
|||
bot.stopLoop() |
|||
} |
|||
} |
|||
} |
|||
|
|||
func init() { |
|||
irc.Handle(handler) |
|||
} |
@ -0,0 +1,53 @@ |
|||
package config |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"os" |
|||
"sync" |
|||
) |
|||
|
|||
// Config represents the logbot's configuration.
|
|||
type Config struct { |
|||
Bot struct { |
|||
Nicks []string `json:"nicks"` |
|||
Server string `json:"server"` |
|||
CommandChannel string `json:"commandChannel"` |
|||
} `json:"bot"` |
|||
API struct { |
|||
Endpoint string `json:"endpoint"` |
|||
Username string `json:"username"` |
|||
Key struct { |
|||
ID string `json:"id"` |
|||
Secret string `json:"secret"` |
|||
} `json:"key"` |
|||
} `json:"api"` |
|||
} |
|||
|
|||
var mutex sync.Mutex |
|||
var config *Config |
|||
|
|||
// Get lazy-loads the configuration in a thread-safe manner.
|
|||
func Get() Config { |
|||
mutex.Lock() |
|||
defer mutex.Unlock() |
|||
|
|||
if config == nil { |
|||
config = &Config{} |
|||
|
|||
file, err := os.Open("/etc/aiterp/logbot3.json") |
|||
if err != nil { |
|||
fmt.Fprintf(os.Stderr, "Failed to load config: %s\n", err) |
|||
os.Exit(1) |
|||
} |
|||
defer file.Close() |
|||
|
|||
err = json.NewDecoder(file).Decode(&config) |
|||
if err != nil { |
|||
fmt.Fprintf(os.Stderr, "Failed to parse config: %s\n", err) |
|||
os.Exit(1) |
|||
} |
|||
} |
|||
|
|||
return *config |
|||
} |
@ -0,0 +1,49 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"errors" |
|||
"time" |
|||
) |
|||
|
|||
// A Change is a change in the timeline.
|
|||
type Change struct { |
|||
ID string `json:"id"` |
|||
Model string `json:"model"` |
|||
Op string `json:"op"` |
|||
Author string `json:"author"` |
|||
Date time.Time `json:"date"` |
|||
Objects []ChangeObject `json:"objects"` |
|||
} |
|||
|
|||
// A ChangeObject is an object affected by the change.
|
|||
type ChangeObject struct { |
|||
Data json.RawMessage `json:"-"` |
|||
TypeName string `json:"__typename"` |
|||
} |
|||
|
|||
// Channel parses the ChangeObject as a channel.
|
|||
func (co *ChangeObject) Channel() (Channel, error) { |
|||
if co.TypeName != "Channel" { |
|||
return Channel{}, errors.New("Incorrect Type Name") |
|||
} |
|||
|
|||
channel := Channel{} |
|||
err := json.Unmarshal(co.Data, &channel) |
|||
|
|||
return channel, err |
|||
} |
|||
|
|||
// UnmarshalJSON implements json.Unmarshaller
|
|||
func (co *ChangeObject) UnmarshalJSON(b []byte) error { |
|||
data := make([]byte, len(b)) |
|||
copy(data, b) |
|||
co.Data = data |
|||
|
|||
err := json.Unmarshal(data, co) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
@ -0,0 +1,9 @@ |
|||
package models |
|||
|
|||
// Channel represents
|
|||
type Channel struct { |
|||
Name string |
|||
Logged bool |
|||
EventName string |
|||
LocationName string |
|||
} |
@ -0,0 +1,40 @@ |
|||
package channels |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/api" |
|||
"git.aiterp.net/rpdata/logbot3/internal/models" |
|||
) |
|||
|
|||
type findResult struct { |
|||
Channel models.Channel `json:"channel"` |
|||
} |
|||
|
|||
var findGQL = ` |
|||
query FindChannel($name: String!) { |
|||
channel(name: $name) { |
|||
name |
|||
logged |
|||
eventName |
|||
locationName |
|||
} |
|||
} |
|||
` |
|||
|
|||
// Find finds a channel by name
|
|||
func Find(ctx context.Context, name string) (models.Channel, error) { |
|||
data, err := api.Global().Query(ctx, findGQL, map[string]interface{}{"name": name}, nil) |
|||
if err != nil { |
|||
return models.Channel{}, err |
|||
} |
|||
|
|||
result := findResult{} |
|||
err = json.Unmarshal(data, &result) |
|||
if err != nil { |
|||
return models.Channel{}, err |
|||
} |
|||
|
|||
return result.Channel, nil |
|||
} |
@ -0,0 +1,40 @@ |
|||
package channels |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/api" |
|||
"git.aiterp.net/rpdata/logbot3/internal/models" |
|||
) |
|||
|
|||
type listLoggedResult struct { |
|||
Channels []models.Channel `json:"channels"` |
|||
} |
|||
|
|||
var listLoggedGQL = ` |
|||
query ListLogged { |
|||
channels(filter: {logged: true}) { |
|||
name |
|||
logged |
|||
eventName |
|||
locationName |
|||
} |
|||
} |
|||
` |
|||
|
|||
// ListLogged gets all open channels
|
|||
func ListLogged(ctx context.Context) ([]models.Channel, error) { |
|||
data, err := api.Global().Query(ctx, listLoggedGQL, nil, nil) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
result := listLoggedResult{} |
|||
err = json.Unmarshal(data, &result) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return result.Channels, nil |
|||
} |
@ -0,0 +1,40 @@ |
|||
package channels |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/api" |
|||
"git.aiterp.net/rpdata/logbot3/internal/models" |
|||
) |
|||
|
|||
type setLoggedResult struct { |
|||
EditChannel models.Channel `json:"editChannel"` |
|||
} |
|||
|
|||
var setLoggedGQL = ` |
|||
mutation SetChanelLogged($name: String!, $logged: Boolean!) { |
|||
editChannel(input: {name: $name, logged: $logged}) { |
|||
name |
|||
logged |
|||
eventName |
|||
locationName |
|||
} |
|||
} |
|||
` |
|||
|
|||
// SetLogged sets a channel as logged
|
|||
func SetLogged(ctx context.Context, name string, logged bool) (models.Channel, error) { |
|||
data, err := api.Global().Query(ctx, setLoggedGQL, map[string]interface{}{"name": name, "logged": logged}, []string{"channel.edit"}) |
|||
if err != nil { |
|||
return models.Channel{}, err |
|||
} |
|||
|
|||
result := setLoggedResult{} |
|||
err = json.Unmarshal(data, &result) |
|||
if err != nil { |
|||
return models.Channel{}, err |
|||
} |
|||
|
|||
return result.EditChannel, nil |
|||
} |
@ -0,0 +1,30 @@ |
|||
package channels |
|||
|
|||
import ( |
|||
"context" |
|||
"time" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/models" |
|||
) |
|||
|
|||
// SubscribeLogged returns a channel that gets all the channel changes.
|
|||
func SubscribeLogged(ctx context.Context) (<-chan models.Channel, error) { |
|||
channels, err := ListLogged(ctx) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
output := make(chan models.Channel, len(channels)+8) |
|||
for _, channel := range channels { |
|||
output <- channel |
|||
} |
|||
|
|||
go func() { |
|||
for { |
|||
time.Sleep(time.Second * 20) |
|||
|
|||
} |
|||
}() |
|||
|
|||
return output, nil |
|||
} |
@ -0,0 +1,26 @@ |
|||
package models |
|||
|
|||
// A User is the information about ID and permission for a user, usually
|
|||
// the caller.
|
|||
type User struct { |
|||
ID string `json:"id"` |
|||
Permissions []string `json:"permissions"` |
|||
} |
|||
|
|||
// LoggedIn returns true if the user is logged-in.
|
|||
func (user *User) LoggedIn() bool { |
|||
return user.ID != "" |
|||
} |
|||
|
|||
// Permitted gets whether the user has this permission.
|
|||
func (user *User) Permitted(permissions ...string) bool { |
|||
for _, userPermission := range user.Permissions { |
|||
for _, permission := range permissions { |
|||
if permission == userPermission { |
|||
return true |
|||
} |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
@ -0,0 +1,42 @@ |
|||
package users |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/api" |
|||
"git.aiterp.net/rpdata/logbot3/internal/models" |
|||
) |
|||
|
|||
type checkTokenResult struct { |
|||
Token struct { |
|||
User models.User `json:"user"` |
|||
} `json:"token"` |
|||
} |
|||
|
|||
var checkTokenGQL = ` |
|||
query CheckTokenQuery { |
|||
token { |
|||
user { |
|||
id |
|||
permissions |
|||
} |
|||
} |
|||
} |
|||
` |
|||
|
|||
// CheckToken checks the own token and returns the user information.
|
|||
func CheckToken(ctx context.Context) (models.User, error) { |
|||
data, err := api.Global().Query(ctx, checkTokenGQL, nil, []string{"member"}) |
|||
if err != nil { |
|||
return models.User{}, err |
|||
} |
|||
|
|||
result := checkTokenResult{} |
|||
err = json.Unmarshal(data, &result) |
|||
if err != nil { |
|||
return models.User{}, err |
|||
} |
|||
|
|||
return result.Token.User, nil |
|||
} |
@ -0,0 +1,41 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"context" |
|||
"log" |
|||
"os" |
|||
"os/signal" |
|||
"strings" |
|||
"syscall" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/bot" |
|||
"git.aiterp.net/rpdata/logbot3/internal/config" |
|||
|
|||
"git.aiterp.net/rpdata/logbot3/internal/models/users" |
|||
) |
|||
|
|||
func main() { |
|||
conf := config.Get() |
|||
|
|||
user, err := users.CheckToken(context.Background()) |
|||
if user.LoggedIn() { |
|||
log.Printf("Logged in: %s (%s)", user.ID, strings.Join(user.Permissions, ", ")) |
|||
} else { |
|||
log.Println("Warning: API key did not gain us access:", err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
bot := bot.New(context.Background(), conf.Bot.Nicks[0], conf.Bot.Nicks[1:]) |
|||
err = bot.Connect(conf.Bot.Server, false) |
|||
if err != nil { |
|||
log.Println("Warning:", err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
// Listen for a quit signal.
|
|||
interrupt := make(chan os.Signal, 1) |
|||
signal.Notify(interrupt, os.Interrupt) |
|||
signal.Notify(interrupt, syscall.SIGTERM) |
|||
|
|||
log.Println("Interrupt received, stopping...") |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue