forked from hswaw/hscloud
lelegram: init
This is an IRC/Telegram bridge. It does multi-account puppet-like access to IRC making everyone's life easier. Compared to teleirc it also: - is smarter about converting messages - uses teleimg for public image access - is not written in JS Experimental for now. Change-Id: I66ba3f83abdfdea6463ab3be5380d8d3f2769291
This commit is contained in:
parent
c315aaccc7
commit
a885488fd0
12 changed files with 1221 additions and 4 deletions
18
WORKSPACE
18
WORKSPACE
|
@ -1904,3 +1904,21 @@ go_repository(
|
|||
remote = "https://github.com/ulule/limiter",
|
||||
vcs = "git",
|
||||
)
|
||||
|
||||
go_repository(
|
||||
name = "com_github_go_telegram_bot_api_telegram_bot_api",
|
||||
commit = "b33efeebc78563cfeddf19563781cffb16aaabdf",
|
||||
importpath = "github.com/go-telegram-bot-api/telegram-bot-api",
|
||||
)
|
||||
|
||||
go_repository(
|
||||
name = "com_github_technoweenie_multipartstreamer",
|
||||
commit = "a90a01d73ae432e2611d178c18367fbaa13e0154",
|
||||
importpath = "github.com/technoweenie/multipartstreamer",
|
||||
)
|
||||
|
||||
go_repository(
|
||||
name = "in_gopkg_irc_v3",
|
||||
commit = "d07dcb9293789fdc99c797d3499a5799bc343b86",
|
||||
importpath = "gopkg.in/irc.v3",
|
||||
)
|
||||
|
|
|
@ -19,7 +19,8 @@ local teleimg = import "teleimg.libsonnet";
|
|||
components: {
|
||||
smsgw: smsgw.component(cfg.smsgw, env),
|
||||
ldapweb: ldapweb.component(cfg.ldapweb, env),
|
||||
teleimg: teleimg.component(cfg.teleimg, env),
|
||||
teleimg: teleimg.teleimg(cfg.teleimg, env),
|
||||
lelegram: teleimg.lelegram(cfg.teleimg, env),
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -6,14 +6,21 @@ local kube = import "../../kube/kube.libsonnet";
|
|||
secret: {
|
||||
telegram_token: error "telegram_token must be set",
|
||||
},
|
||||
image: "registry.k0.hswaw.net/q3k/teleimg:1578240550-1525c84e4cef4f382e2dca2210f31830533dc7c4",
|
||||
image: {
|
||||
teleimg: "registry.k0.hswaw.net/q3k/teleimg:1578243230-79e4e790f877597c3175823ee7783eb99744dc27",
|
||||
lelegram: "registry.k0.hswaw.net/q3k/lelegram:1578253551-79e4e790f877597c3175823ee7783eb99744dc27",
|
||||
},
|
||||
bridge: {
|
||||
telegram: "-1001345766954",
|
||||
irc: "#hackerspace-krk",
|
||||
},
|
||||
webFQDN: error "webFQDN must be set!",
|
||||
},
|
||||
|
||||
component(cfg, env):: mirko.Component(env, "teleimg") {
|
||||
teleimg(cfg, env):: mirko.Component(env, "teleimg") {
|
||||
local teleimg = self,
|
||||
cfg+: {
|
||||
image: cfg.image,
|
||||
image: cfg.image.teleimg,
|
||||
container: teleimg.GoContainer("main", "/teleimg/teleimg") {
|
||||
env_: {
|
||||
TELEGRAM_TOKEN: kube.SecretKeyRef(teleimg.secret, "telegram_token"),
|
||||
|
@ -38,4 +45,27 @@ local kube = import "../../kube/kube.libsonnet";
|
|||
data: cfg.secret,
|
||||
},
|
||||
},
|
||||
|
||||
lelegram(cfg, env):: mirko.Component(env, "lelegram") {
|
||||
local lelegram = self,
|
||||
cfg+: {
|
||||
image: cfg.image.lelegram,
|
||||
container: lelegram.GoContainer("main", "/lelegram/lelegram") {
|
||||
env_: {
|
||||
TELEGRAM_TOKEN: kube.SecretKeyRef(lelegram.secret, "telegram_token"),
|
||||
},
|
||||
command+: [
|
||||
"-telegram_token", "$(TELEGRAM_TOKEN)",
|
||||
"-telegram_chat", cfg.bridge.telegram,
|
||||
"-irc_channel", cfg.bridge.irc,
|
||||
"-irc_max_connections", "10",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
secret: kube.Secret("lelegram") {
|
||||
metadata+: lelegram.metadata,
|
||||
data: cfg.secret,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
49
personal/q3k/lelegram/BUILD.bazel
Normal file
49
personal/q3k/lelegram/BUILD.bazel
Normal file
|
@ -0,0 +1,49 @@
|
|||
load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_layer", "container_push")
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "go_default_library",
|
||||
srcs = [
|
||||
"main.go",
|
||||
"telegram.go",
|
||||
],
|
||||
importpath = "code.hackerspace.pl/hscloud/personal/q3k/lelegram",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
"//go/mirko:go_default_library",
|
||||
"//personal/q3k/lelegram/irc:go_default_library",
|
||||
"@com_github_go_telegram_bot_api_telegram_bot_api//:go_default_library",
|
||||
"@com_github_golang_glog//:go_default_library",
|
||||
],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "lelegram",
|
||||
embed = [":go_default_library"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
container_layer(
|
||||
name = "layer_bin",
|
||||
files = [
|
||||
":lelegram",
|
||||
],
|
||||
directory = "/lelegram/",
|
||||
)
|
||||
|
||||
container_image(
|
||||
name = "runtime",
|
||||
base = "@prodimage-bionic//image",
|
||||
layers = [
|
||||
":layer_bin",
|
||||
],
|
||||
)
|
||||
|
||||
container_push(
|
||||
name = "push",
|
||||
image = ":runtime",
|
||||
format = "Docker",
|
||||
registry = "registry.k0.hswaw.net",
|
||||
repository = "q3k/lelegram",
|
||||
tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
|
||||
)
|
18
personal/q3k/lelegram/irc/BUILD.bazel
Normal file
18
personal/q3k/lelegram/irc/BUILD.bazel
Normal file
|
@ -0,0 +1,18 @@
|
|||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "go_default_library",
|
||||
srcs = [
|
||||
"conn.go",
|
||||
"manager.go",
|
||||
"manager_conns.go",
|
||||
"manager_control.go",
|
||||
"manager_event.go",
|
||||
],
|
||||
importpath = "code.hackerspace.pl/hscloud/personal/q3k/lelegram/irc",
|
||||
visibility = ["//personal/q3k/lelegram:__pkg__"],
|
||||
deps = [
|
||||
"@com_github_golang_glog//:go_default_library",
|
||||
"@in_gopkg_irc_v3//:go_default_library",
|
||||
],
|
||||
)
|
282
personal/q3k/lelegram/irc/conn.go
Normal file
282
personal/q3k/lelegram/irc/conn.go
Normal file
|
@ -0,0 +1,282 @@
|
|||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
irc "gopkg.in/irc.v3"
|
||||
)
|
||||
|
||||
// ircconn is a connection to IRC as a given user.
|
||||
type ircconn struct {
|
||||
// server to connect to
|
||||
server string
|
||||
// channel to join
|
||||
channel string
|
||||
// 'native' name of this connection.
|
||||
user string
|
||||
|
||||
// Event Handler, usually a Manager
|
||||
eventHandler func(e *event)
|
||||
|
||||
// TCP connection to IRC
|
||||
conn net.Conn
|
||||
// IRC client
|
||||
irc *irc.Client
|
||||
|
||||
/// Fields used by the manager - do not access from ircconn.
|
||||
// last time this connection was used
|
||||
last time.Time
|
||||
// is primary source of IRC data
|
||||
receiver bool
|
||||
// only exists to be a receiver
|
||||
backup bool
|
||||
// iq is the IRC Queue of IRC messages, populated by the IRC client and
|
||||
// read by the connection.
|
||||
iq chan *irc.Message
|
||||
// sq is the Say Queue of controlMessages, populated by the Manager and
|
||||
// read by the connection (and passed onto IRC)
|
||||
sq chan *controlMessage
|
||||
// eq is the Evict Queue, used by the manager to signal that a connection
|
||||
// should die.
|
||||
eq chan struct{}
|
||||
|
||||
// connected is a flag (via sync/atomic) that is used to signal to the
|
||||
// manager that this connection is up and healthy.
|
||||
connected int64
|
||||
}
|
||||
|
||||
// Say is called by the Manager when a message should be sent out by the
|
||||
// connection.
|
||||
func (i *ircconn) Say(msg *controlMessage) {
|
||||
i.sq <- msg
|
||||
}
|
||||
|
||||
// Evict is called by the Manager when a connection should die.
|
||||
func (i *ircconn) Evict() {
|
||||
close(i.eq)
|
||||
}
|
||||
|
||||
// ircMessage is a message received on IRC by a connection, sent over to the
|
||||
// Manager.
|
||||
type IRCMessage struct {
|
||||
conn *ircconn
|
||||
nick string
|
||||
text string
|
||||
}
|
||||
|
||||
func NewConn(server, channel, user string, backup bool, h func(e *event)) (*ircconn, error) {
|
||||
glog.Infof("Connecting to IRC/%s/%s/%s...", server, channel, user)
|
||||
conn, err := net.Dial("tcp", server)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Dial(_, %q): %v", server, err)
|
||||
}
|
||||
|
||||
i := &ircconn{
|
||||
server: server,
|
||||
channel: channel,
|
||||
user: user,
|
||||
|
||||
eventHandler: h,
|
||||
|
||||
conn: conn,
|
||||
irc: nil,
|
||||
|
||||
last: time.Now(),
|
||||
backup: backup,
|
||||
receiver: backup,
|
||||
|
||||
iq: make(chan *irc.Message),
|
||||
sq: make(chan *controlMessage),
|
||||
eq: make(chan struct{}),
|
||||
|
||||
connected: int64(0),
|
||||
}
|
||||
|
||||
// Generate IRC nick from username.
|
||||
nick := user
|
||||
if len(nick) > 13 {
|
||||
nick = nick[:13]
|
||||
}
|
||||
nick += "[t]"
|
||||
|
||||
// Configure IRC client to populate the IRC Queue.
|
||||
config := irc.ClientConfig{
|
||||
Nick: nick,
|
||||
User: user,
|
||||
Name: user,
|
||||
Handler: irc.HandlerFunc(func(c *irc.Client, m *irc.Message) {
|
||||
i.iq <- m
|
||||
}),
|
||||
}
|
||||
|
||||
i.irc = irc.NewClient(conn, config)
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (i *ircconn) Run(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
i.loop(ctx)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
err := i.irc.RunContext(ctx)
|
||||
if err != ctx.Err() {
|
||||
glog.Errorf("IRC/%s/%s/%s exited: %v", i.server, i.channel, i.user, err)
|
||||
i.conn.Close()
|
||||
i.eventHandler(&event{
|
||||
dead: &eventDead{i},
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// IsConnected returns whether a connection is fully alive and able to receive
|
||||
// messages.
|
||||
func (i *ircconn) IsConnected() bool {
|
||||
return atomic.LoadInt64(&i.connected) > 0
|
||||
}
|
||||
|
||||
// loop is the main loop of an IRC connection.
|
||||
// It synchronizes the Handler Queue, Say Queue and Evict Queue, parses
|
||||
func (i *ircconn) loop(ctx context.Context) {
|
||||
sayqueue := []*controlMessage{}
|
||||
connected := false
|
||||
dead := false
|
||||
|
||||
die := func() {
|
||||
dead = true
|
||||
i.conn.Close()
|
||||
go i.eventHandler(&event{
|
||||
dead: &eventDead{i},
|
||||
})
|
||||
}
|
||||
msg := func(s *controlMessage) {
|
||||
lines := strings.Split(s.message, "\n")
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
err := i.irc.WriteMessage(&irc.Message{
|
||||
Command: "PRIVMSG",
|
||||
Params: []string{
|
||||
i.channel,
|
||||
l,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
glog.Errorf("IRC/%s: WriteMessage: %v", i.user, err)
|
||||
die()
|
||||
s.done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
s.done <- nil
|
||||
}
|
||||
|
||||
// Timeout ticker - give up connecting to IRC after 15 seconds.
|
||||
t := time.NewTicker(time.Second * 15)
|
||||
|
||||
previousNick := ""
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-i.eq:
|
||||
glog.Infof("IRC/%s/info: got evicted", i.user)
|
||||
die()
|
||||
return
|
||||
|
||||
case m := <-i.iq:
|
||||
if m.Command != "372" {
|
||||
glog.V(1).Infof("IRC/%s/debug: %+v", i.user, m)
|
||||
}
|
||||
|
||||
switch {
|
||||
case m.Command == "001":
|
||||
glog.Infof("IRC/%s/info: joining %s...", i.user, i.channel)
|
||||
i.irc.Write("JOIN " + i.channel)
|
||||
|
||||
case m.Command == "353":
|
||||
glog.Infof("IRC/%s/info: joined and ready", i.user)
|
||||
connected = true
|
||||
atomic.StoreInt64(&i.connected, 1)
|
||||
// drain queue of say messages...
|
||||
for _, s := range sayqueue {
|
||||
glog.Infof("IRC/%s/say: [backlog] %q", i.user, s.message)
|
||||
msg(s)
|
||||
}
|
||||
sayqueue = []*controlMessage{}
|
||||
|
||||
case m.Command == "474":
|
||||
// We are banned! :(
|
||||
glog.Infof("IRC/%s/info: banned!", i.user)
|
||||
go i.eventHandler(&event{
|
||||
banned: &eventBanned{i},
|
||||
})
|
||||
// drain queue of say messages...
|
||||
for _, s := range sayqueue {
|
||||
glog.Infof("IRC/%s/say: [drop] %q", i.user, s.message)
|
||||
s.done <- nil
|
||||
}
|
||||
sayqueue = []*controlMessage{}
|
||||
die()
|
||||
return
|
||||
|
||||
case m.Command == "KICK" && m.Params[1] == i.irc.CurrentNick():
|
||||
glog.Infof("IRC/%s/info: got kicked", i.user)
|
||||
die()
|
||||
return
|
||||
|
||||
case m.Command == "PRIVMSG" && m.Params[0] == i.channel:
|
||||
go i.eventHandler(&event{
|
||||
message: &eventMessage{i, m.Prefix.Name, m.Params[1]},
|
||||
})
|
||||
}
|
||||
|
||||
// update nickmap if needed
|
||||
nick := i.irc.CurrentNick()
|
||||
if previousNick != nick {
|
||||
i.eventHandler(&event{
|
||||
nick: &eventNick{i, nick},
|
||||
})
|
||||
previousNick = nick
|
||||
}
|
||||
|
||||
case s := <-i.sq:
|
||||
if dead {
|
||||
glog.Infof("IRC/%s/say: [DEAD] %q", i.user, s.message)
|
||||
s.done <- fmt.Errorf("connection is dead")
|
||||
} else if connected {
|
||||
glog.Infof("IRC/%s/say: %s", i.user, s.message)
|
||||
msg(s)
|
||||
} else {
|
||||
glog.Infof("IRC/%s/say: [writeback] %q", i.user, s.message)
|
||||
sayqueue = append(sayqueue, s)
|
||||
}
|
||||
|
||||
case <-t.C:
|
||||
if !connected {
|
||||
glog.Errorf("IRC/%s/info: connection timed out, dying", i.user)
|
||||
die()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
161
personal/q3k/lelegram/irc/manager.go
Normal file
161
personal/q3k/lelegram/irc/manager.go
Normal file
|
@ -0,0 +1,161 @@
|
|||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
)
|
||||
|
||||
// Manager maintains a set of IRC connections to a server and channel. Its has
|
||||
// three interfaces to the outside world:
|
||||
// - control, from the owner of Manager (eg. a bridge to another protocol)
|
||||
// that allows sending messages as a given user and to subscribe to
|
||||
// notifications
|
||||
// - events, from IRC connections, to update the manager about a connection
|
||||
// state (lifecycle or nick change)
|
||||
// - subscriptions, that pass received messages from IRC to a channel requested
|
||||
// by control.
|
||||
//
|
||||
// The Manager will maintain exactly one 'receiver', which is an IRC connection
|
||||
// that is used as a source of truth for messages on an IRC channel. This will
|
||||
// either be an existing connection for a user, or a 'backup' connection that
|
||||
// will close as soon as a real/named connection exists and is fully connected.
|
||||
type Manager struct {
|
||||
// maximum IRC sessions to maintain
|
||||
max int
|
||||
// IRC server address
|
||||
server string
|
||||
// IRC channel name
|
||||
channel string
|
||||
// control channel (from owner)
|
||||
ctrl chan *control
|
||||
// event channel (from connections)
|
||||
event chan *event
|
||||
|
||||
// map from user name to IRC connection
|
||||
conns map[string]*ircconn
|
||||
// map from user name to IRC nick
|
||||
nickmap map[string]string
|
||||
// set of users that we shouldn't attempt to bridge, and their expiry times
|
||||
shitlist map[string]time.Time
|
||||
// set of subscribing channels for notifications
|
||||
subscribers map[chan *Notification]bool
|
||||
// context representing the Manager lifecycle
|
||||
runctx context.Context
|
||||
}
|
||||
|
||||
func NewManager(max int, server, channel string) *Manager {
|
||||
return &Manager{
|
||||
max: max,
|
||||
server: server,
|
||||
channel: channel,
|
||||
ctrl: make(chan *control),
|
||||
event: make(chan *event),
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications are sent to subscribers when things happen on IRC
|
||||
type Notification struct {
|
||||
// A new message appeared on the channel
|
||||
Message *NotificationMessage
|
||||
// Nicks of our connections have changed
|
||||
Nickmap *map[string]string
|
||||
}
|
||||
|
||||
// NotificationMessage is a message that happened in the connected IRC channel
|
||||
type NotificationMessage struct {
|
||||
// Nick is the IRC nickname of the sender
|
||||
Nick string
|
||||
// Message is the plaintext message from IRC
|
||||
Message string
|
||||
}
|
||||
|
||||
// Run maintains the main logic of the Manager - servicing control and event
|
||||
// messages, and ensuring there is a receiver on the given channel.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
m.conns = make(map[string]*ircconn)
|
||||
m.nickmap = make(map[string]string)
|
||||
m.shitlist = make(map[string]time.Time)
|
||||
m.subscribers = make(map[chan *Notification]bool)
|
||||
m.runctx = context.Background()
|
||||
|
||||
glog.Infof("IRC Manager %s/%s running...", m.server, m.channel)
|
||||
|
||||
t := time.NewTicker(1 * time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case c := <-m.ctrl:
|
||||
m.doctrl(ctx, c)
|
||||
case e := <-m.event:
|
||||
m.doevent(ctx, e)
|
||||
case <-t.C:
|
||||
}
|
||||
|
||||
m.ensureReceiver(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureReceiver ensures that there is exactly one 'receiver' IRC connection,
|
||||
// possibly creating a backup receiver if needed.
|
||||
func (m *Manager) ensureReceiver(ctx context.Context) {
|
||||
// Ensure backup listener does not exist if there is a named connection
|
||||
active := 0
|
||||
for _, c := range m.conns {
|
||||
if !c.IsConnected() {
|
||||
continue
|
||||
}
|
||||
active += 1
|
||||
}
|
||||
if active > 1 {
|
||||
var backup *ircconn
|
||||
for _, c := range m.conns {
|
||||
if c.backup {
|
||||
backup = c
|
||||
}
|
||||
}
|
||||
if backup != nil {
|
||||
glog.Infof("Evicting backup listener")
|
||||
backup.Evict()
|
||||
delete(m.conns, backup.user)
|
||||
}
|
||||
}
|
||||
// Ensure there exists exactly one reciever
|
||||
count := 0
|
||||
for _, c := range m.conns {
|
||||
if !c.IsConnected() && !c.backup {
|
||||
c.receiver = false
|
||||
continue
|
||||
}
|
||||
if c.receiver {
|
||||
count += 1
|
||||
}
|
||||
if count >= 2 {
|
||||
c.receiver = false
|
||||
}
|
||||
}
|
||||
// No receivers? make one.
|
||||
if count == 0 {
|
||||
if len(m.conns) == 0 {
|
||||
// Noone said anything on telegram, make backup
|
||||
glog.Infof("No receiver found, making backup")
|
||||
name := "lelegram"
|
||||
c, err := m.newconn(ctx, name, true)
|
||||
if err != nil {
|
||||
glog.Errorf("Could not make backup receiver: %v", err)
|
||||
} else {
|
||||
m.conns[name] = c
|
||||
}
|
||||
} else {
|
||||
// Make first conn a receiver
|
||||
glog.Infof("No receiver found, using conn")
|
||||
for _, v := range m.conns {
|
||||
glog.Infof("Elected %s for receiver", v.user)
|
||||
v.receiver = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
64
personal/q3k/lelegram/irc/manager_conns.go
Normal file
64
personal/q3k/lelegram/irc/manager_conns.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
)
|
||||
|
||||
var (
|
||||
errBanned = fmt.Errorf("user is shitlisted")
|
||||
)
|
||||
|
||||
// getconn either gets a connection by username, or creates a new one (after
|
||||
// evicting the least recently used connection).
|
||||
func (m *Manager) getconn(ctx context.Context, user string) (*ircconn, error) {
|
||||
// Is the user shitlisted?
|
||||
if t, ok := m.shitlist[user]; ok && time.Now().Before(t) {
|
||||
return nil, errBanned
|
||||
}
|
||||
// Do we already have a connection?
|
||||
c, ok := m.conns[user]
|
||||
if ok {
|
||||
// Bump and return.
|
||||
c.last = time.Now()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Are we at the limit of allowed connections?
|
||||
if len(m.conns) >= m.max {
|
||||
// Evict least recently used
|
||||
evict := ""
|
||||
lru := time.Now()
|
||||
for _, c := range m.conns {
|
||||
if c.last.Before(lru) {
|
||||
evict = c.user
|
||||
lru = c.last
|
||||
}
|
||||
}
|
||||
if evict == "" {
|
||||
glog.Exitf("could not find connection to evict, %v", m.conns)
|
||||
}
|
||||
m.conns[evict].Evict()
|
||||
delete(m.conns, evict)
|
||||
}
|
||||
|
||||
// Allocate new connection
|
||||
return m.newconn(ctx, user, false)
|
||||
}
|
||||
|
||||
// newconn creates a new IRC connection as a given user, and saves it to the
|
||||
// conns map.
|
||||
func (m *Manager) newconn(ctx context.Context, user string, backup bool) (*ircconn, error) {
|
||||
c, err := NewConn(m.server, m.channel, user, backup, m.Event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.conns[user] = c
|
||||
|
||||
go c.Run(m.runctx)
|
||||
|
||||
return c, nil
|
||||
}
|
85
personal/q3k/lelegram/irc/manager_control.go
Normal file
85
personal/q3k/lelegram/irc/manager_control.go
Normal file
|
@ -0,0 +1,85 @@
|
|||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang/glog"
|
||||
)
|
||||
|
||||
// Control: send a message to IRC.
|
||||
func (m *Manager) SendMessage(user, text string) error {
|
||||
done := make(chan error)
|
||||
m.ctrl <- &control{
|
||||
message: &controlMessage{
|
||||
from: user,
|
||||
message: text,
|
||||
done: done,
|
||||
},
|
||||
}
|
||||
return <-done
|
||||
}
|
||||
|
||||
// Control: subscribe to notifiactions.
|
||||
func (m *Manager) Subscribe(c chan *Notification) {
|
||||
m.ctrl <- &control{
|
||||
subscribe: &controlSubscribe{
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// control message from owner. Only one member can be set.
|
||||
type control struct {
|
||||
// message needs to be send to IRC
|
||||
message *controlMessage
|
||||
// a new subscription channel for notifications is presented
|
||||
subscribe *controlSubscribe
|
||||
}
|
||||
|
||||
// controlMessage is a request to send a message to IRC as a given user
|
||||
type controlMessage struct {
|
||||
// user name (native to application)
|
||||
from string
|
||||
// plaintext message
|
||||
message string
|
||||
// channel that will be sent nil or an error when the message has been
|
||||
// succesfully sent or an error occured
|
||||
done chan error
|
||||
}
|
||||
|
||||
// controlSubscribe is a request to send notifications to a given channel
|
||||
type controlSubscribe struct {
|
||||
c chan *Notification
|
||||
}
|
||||
|
||||
// doctrl processes a given control message.
|
||||
func (m *Manager) doctrl(ctx context.Context, c *control) {
|
||||
switch {
|
||||
|
||||
case c.message != nil:
|
||||
// Send a message to IRC.
|
||||
|
||||
// Find a relevant connection, or make one.
|
||||
conn, err := m.getconn(ctx, c.message.from)
|
||||
if err != nil {
|
||||
// Do not attempt to redeliver bans.
|
||||
if err == errBanned {
|
||||
c.message.done <- nil
|
||||
} else {
|
||||
c.message.done <- fmt.Errorf("getting connection: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Route message to connection.
|
||||
conn.Say(c.message)
|
||||
|
||||
case c.subscribe != nil:
|
||||
// Subscribe to notifications.
|
||||
m.subscribers[c.subscribe.c] = true
|
||||
|
||||
default:
|
||||
glog.Errorf("unhandled control %+v", c)
|
||||
}
|
||||
}
|
153
personal/q3k/lelegram/irc/manager_event.go
Normal file
153
personal/q3k/lelegram/irc/manager_event.go
Normal file
|
@ -0,0 +1,153 @@
|
|||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
)
|
||||
|
||||
func (m *Manager) Event(e *event) {
|
||||
m.event <- e
|
||||
}
|
||||
|
||||
// Event: a connection has a new nick.
|
||||
func (m *Manager) UpdateNickmap(conn *ircconn, nick string) {
|
||||
m.event <- &event{
|
||||
nick: &eventNick{
|
||||
conn: conn,
|
||||
nick: nick,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Event: mark a given connection as dead.
|
||||
func (m *Manager) MarkDead(i *ircconn) {
|
||||
m.event <- &event{
|
||||
dead: &eventDead{
|
||||
conn: i,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// event message from IRC connections. Only one member can be set.
|
||||
type event struct {
|
||||
// a connection has gotten a (new) nick
|
||||
nick *eventNick
|
||||
// a connection received a new PRIVMSG
|
||||
message *eventMessage
|
||||
// a connection is banned
|
||||
banned *eventBanned
|
||||
// a connection died
|
||||
dead *eventDead
|
||||
}
|
||||
|
||||
// eventNick is emitted when a connection has received a new nickname from IRC
|
||||
type eventNick struct {
|
||||
conn *ircconn
|
||||
nick string
|
||||
}
|
||||
|
||||
// eventMessage is emitted when there is a PRIVMSG to the IRC channel. This
|
||||
// does not contain messages sent by ourselves, and messages are deduplicated
|
||||
// from multiple active IRC connections.
|
||||
type eventMessage struct {
|
||||
conn *ircconn
|
||||
nick string
|
||||
message string
|
||||
}
|
||||
|
||||
// eventBanned is amitted when a connection is banned from a channel.
|
||||
type eventBanned struct {
|
||||
conn *ircconn
|
||||
}
|
||||
|
||||
// eventDead is emitted when a connection has died and needs to be disposed of
|
||||
type eventDead struct {
|
||||
conn *ircconn
|
||||
}
|
||||
|
||||
func (m *Manager) notifyAll(n *Notification) {
|
||||
for s, _ := range m.subscribers {
|
||||
go func(c chan *Notification, n *Notification) {
|
||||
c <- n
|
||||
}(s, n)
|
||||
}
|
||||
}
|
||||
|
||||
// doevent handles incoming events.
|
||||
func (m *Manager) doevent(ctx context.Context, e *event) {
|
||||
switch {
|
||||
case e.nick != nil:
|
||||
// Nick update from connection
|
||||
|
||||
// Ensure this connection is still used.
|
||||
if m.conns[e.nick.conn.user] != e.nick.conn {
|
||||
return
|
||||
}
|
||||
|
||||
// Edge-detect changes.
|
||||
changed := false
|
||||
if m.nickmap[e.nick.conn.user] != e.nick.nick {
|
||||
glog.Infof("Event: Nick change for %s: %q -> %q", e.nick.conn.user, m.nickmap[e.nick.conn.user], e.nick.nick)
|
||||
m.nickmap[e.nick.conn.user] = e.nick.nick
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
|
||||
// Notify subscribers about a new nickmap.
|
||||
nm := make(map[string]string)
|
||||
for k, v := range m.nickmap {
|
||||
nm[k] = v
|
||||
}
|
||||
m.notifyAll(&Notification{
|
||||
Nickmap: &nm,
|
||||
})
|
||||
|
||||
case e.banned != nil:
|
||||
// A connection is banned. Shitlist the given user to not retry again.
|
||||
user := e.banned.conn.user
|
||||
glog.Infof("Event: %s is banned!", user)
|
||||
m.shitlist[user] = time.Now().Add(time.Hour)
|
||||
|
||||
case e.dead != nil:
|
||||
// Dead update from connection.
|
||||
|
||||
// Ensure this connection is still used.
|
||||
if m.conns[e.dead.conn.user] != e.dead.conn {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete connection.
|
||||
glog.Infof("Event: Connection for %s died", e.dead.conn.user)
|
||||
delete(m.conns, e.dead.conn.user)
|
||||
|
||||
case e.message != nil:
|
||||
// Route messages from receivers.
|
||||
|
||||
// Drop non-receiver events.
|
||||
if !e.message.conn.receiver {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure this is not from us.
|
||||
for _, i := range m.nickmap {
|
||||
if e.message.nick == i {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.notifyAll(&Notification{
|
||||
Message: &NotificationMessage{
|
||||
Nick: e.message.nick,
|
||||
Message: e.message.message,
|
||||
},
|
||||
})
|
||||
|
||||
default:
|
||||
glog.Errorf("Event: Unhandled event %+v", e)
|
||||
}
|
||||
}
|
187
personal/q3k/lelegram/main.go
Normal file
187
personal/q3k/lelegram/main.go
Normal file
|
@ -0,0 +1,187 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.hackerspace.pl/hscloud/go/mirko"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
|
||||
"github.com/golang/glog"
|
||||
|
||||
"code.hackerspace.pl/hscloud/personal/q3k/lelegram/irc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.Set("logtostderr", "true")
|
||||
}
|
||||
|
||||
var (
|
||||
flagTelegramToken string
|
||||
flagTelegramChat string
|
||||
flagTeleimgRoot string
|
||||
flagIRCMaxConnections int
|
||||
flagIRCServer string
|
||||
flagIRCChannel string
|
||||
)
|
||||
|
||||
// server is responsible for briding IRC and Telegram.
|
||||
type server struct {
|
||||
// groupId is the Telegram Group ID to bridge.
|
||||
groupId int64
|
||||
tel *tgbotapi.BotAPI
|
||||
mgr *irc.Manager
|
||||
|
||||
// backlog from telegram
|
||||
telLog chan *telegramPlain
|
||||
// backlog from IRC
|
||||
ircLog chan *irc.Notification
|
||||
}
|
||||
|
||||
// telegramPlain is a plaintext telegram message - ie. one that's ready to send
|
||||
// to IRC, possibly in mutliple lines.
|
||||
type telegramPlain struct {
|
||||
// Telegram name that sent message - without '@'.
|
||||
user string
|
||||
// Plain text of message, possibly multiline.
|
||||
text string
|
||||
}
|
||||
|
||||
func newServer(groupId int64, mgr *irc.Manager) (*server, error) {
|
||||
tel, err := tgbotapi.NewBotAPI(flagTelegramToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("when creating telegram bot: %v", err)
|
||||
}
|
||||
|
||||
glog.Infof("Authorized with Telegram as %q", tel.Self.UserName)
|
||||
|
||||
return &server{
|
||||
groupId: groupId,
|
||||
tel: tel,
|
||||
mgr: mgr,
|
||||
|
||||
telLog: make(chan *telegramPlain),
|
||||
ircLog: make(chan *irc.Notification),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&flagTelegramToken, "telegram_token", "", "Telegram Bot API Token")
|
||||
flag.StringVar(&flagTelegramChat, "telegram_chat", "", "Telegram chat/group ID to bridge. If not given, bridge will start in lame mode and allow you to find out IDs of groups which the bridge bot is part of")
|
||||
flag.StringVar(&flagTeleimgRoot, "teleimg_root", "https://teleimg.hswaw.net/fileid/", "Root URL of teleimg file serving URL")
|
||||
flag.IntVar(&flagIRCMaxConnections, "irc_max_connections", 10, "How many simulataneous connections can there be to IRC before they get recycled")
|
||||
flag.StringVar(&flagIRCServer, "irc_server", "chat.freenode.net:6667", "The address (with port) of the IRC server to connect to")
|
||||
flag.StringVar(&flagIRCChannel, "irc_channel", "", "The channel name (including hash(es)) to bridge")
|
||||
flag.Parse()
|
||||
|
||||
if flagTelegramToken == "" {
|
||||
glog.Exitf("telegram_token must be set")
|
||||
}
|
||||
|
||||
if flagIRCChannel == "" {
|
||||
glog.Exitf("irc_channel must be set")
|
||||
}
|
||||
|
||||
// Parse given group ID.
|
||||
// If not set, start server in 'lame' mode, ie. one that will not actually
|
||||
// perform any bridging, but will let you figure out the IDs of groups that
|
||||
// this bot is part of.
|
||||
var groupId int64
|
||||
if flagTelegramChat == "" {
|
||||
glog.Warningf("telegram_chat NOT GIVEN, STARTING IN LAME MODE")
|
||||
glog.Warningf("Watch for logs to find out the ID of groups which this bot is part of. Then, restart the bot with telegram_chat set.")
|
||||
} else {
|
||||
g, err := strconv.ParseInt(flagTelegramChat, 10, 64)
|
||||
if err != nil {
|
||||
glog.Exitf("telegram_chat must be a number")
|
||||
}
|
||||
groupId = g
|
||||
}
|
||||
|
||||
m := mirko.New()
|
||||
if err := m.Listen(); err != nil {
|
||||
glog.Exitf("Listen(): %v", err)
|
||||
}
|
||||
|
||||
mgr := irc.NewManager(flagIRCMaxConnections, flagIRCServer, flagIRCChannel)
|
||||
|
||||
s, err := newServer(groupId, mgr)
|
||||
if err != nil {
|
||||
glog.Exitf("newServer(): %v", err)
|
||||
}
|
||||
|
||||
if err := m.Serve(); err != nil {
|
||||
glog.Exitf("Serve(): %v", err)
|
||||
}
|
||||
|
||||
ctx := m.Context()
|
||||
|
||||
// Start IRC manager
|
||||
go mgr.Run(ctx)
|
||||
|
||||
// Start piping Telegram messages into telLog
|
||||
go s.telegramLoop(ctx)
|
||||
|
||||
// Start piping IRC messages into ircLog
|
||||
mgr.Subscribe(s.ircLog)
|
||||
|
||||
// Start message processing bridge (connecting telLog and ircLog)
|
||||
go s.bridge(ctx)
|
||||
|
||||
<-m.Done()
|
||||
}
|
||||
|
||||
// bridge connects telLog with ircLog, exchanging messages both ways and
|
||||
// performing nick translation given an up-to-date nickmap.
|
||||
func (s *server) bridge(ctx context.Context) {
|
||||
nickmap := make(map[string]string)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case m := <-s.telLog:
|
||||
// Event from Telegram (message). Translate Telegram names into IRC names.
|
||||
text := m.text
|
||||
for t, i := range nickmap {
|
||||
text = strings.ReplaceAll(text, "@"+t, i)
|
||||
}
|
||||
glog.Infof("telegram/%s: %v", m.user, text)
|
||||
|
||||
// Attempt to route message to IRC twice.
|
||||
// This blocks until success or failure, making sure the log stays
|
||||
// totally ordered in the face of some of our IRC connections being
|
||||
// dead/slow.
|
||||
err := s.mgr.SendMessage(m.user, text)
|
||||
if err != nil {
|
||||
glog.Warningf("Attempting redelivery of %v after error: %v...", m, err)
|
||||
err = s.mgr.SendMessage(m.user, text)
|
||||
glog.Errorf("Redelivery of %v failed: %v...", m, err)
|
||||
}
|
||||
|
||||
case n := <-s.ircLog:
|
||||
// Notification from IRC (message or new nickmap)
|
||||
switch {
|
||||
case n.Nickmap != nil:
|
||||
// Nicks on IRC changed.
|
||||
for k, v := range *n.Nickmap {
|
||||
nickmap[k] = v
|
||||
}
|
||||
glog.Infof("New nickmap: %v", nickmap)
|
||||
|
||||
case n.Message != nil:
|
||||
// New IRC message. Translate IRC names into Telegram names.
|
||||
text := n.Message.Message
|
||||
for t, i := range nickmap {
|
||||
text = strings.ReplaceAll(text, i, "@"+t)
|
||||
}
|
||||
// And send message to Telegram.
|
||||
msg := tgbotapi.NewMessage(s.groupId, fmt.Sprintf("<%s> %s", n.Message.Nick, text))
|
||||
s.tel.Send(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
169
personal/q3k/lelegram/telegram.go
Normal file
169
personal/q3k/lelegram/telegram.go
Normal file
|
@ -0,0 +1,169 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
|
||||
"github.com/golang/glog"
|
||||
)
|
||||
|
||||
// telegramConnection runs a long-lived connection to the Telegram API to receive
|
||||
// updates and pipe resulting messages into telLog.
|
||||
func (s *server) telegramConnection(ctx context.Context) error {
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
// TODO(q3k): figure out what the _fuck_ does this even mean
|
||||
u.Timeout = 60
|
||||
|
||||
updates, err := s.tel.GetUpdatesChan(u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetUpdatesChan(%+v): %v", u, err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case u, ok := <-updates:
|
||||
if !ok {
|
||||
return fmt.Errorf("Updates channel closed")
|
||||
}
|
||||
|
||||
// Dispatch update.
|
||||
switch {
|
||||
case u.Message != nil:
|
||||
if u.Message.Chat.ID != s.groupId {
|
||||
glog.Infof("[ignored group %d] <%s> %v", u.Message.Chat.ID, u.Message.From, u.Message.Text)
|
||||
continue
|
||||
}
|
||||
if msg := plainFromTelegram(s.tel.Self.ID, &u); msg != nil {
|
||||
s.telLog <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// telegramLoop maintains a telegramConnection.
|
||||
func (s *server) telegramLoop(ctx context.Context) {
|
||||
for {
|
||||
err := s.telegramConnection(ctx)
|
||||
if err == ctx.Err() {
|
||||
glog.Infof("Telegram connection closing: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
glog.Errorf("Telegram connection error: %v", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(1 * time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plainFromTelegram turns a Telegram message into a plain text message.
|
||||
func plainFromTelegram(selfID int, u *tgbotapi.Update) *telegramPlain {
|
||||
parts := []string{}
|
||||
|
||||
from := u.Message.From
|
||||
replyto := u.Message.ReplyToMessage
|
||||
text := u.Message.Text
|
||||
|
||||
// This message is in reply to someone.
|
||||
if replyto != nil && text != "" && replyto.From != nil {
|
||||
// The rendered name of the author of the quote.
|
||||
ruid := "@" + replyto.From.String()
|
||||
|
||||
// First line of the quoted text.
|
||||
quotedLine := ""
|
||||
|
||||
// Check if the quoted message is from our bridge.
|
||||
if replyto.From.ID == selfID {
|
||||
// Someone replied to an IRC bridge message, extract nick and line from there
|
||||
// eg: "<q3k> foo bar baz" -> ruid = q3k; quotedLine = foo bar baz
|
||||
t := replyto.Text
|
||||
if strings.HasPrefix(t, "<") {
|
||||
p := strings.SplitN(t[1:], ">", 2)
|
||||
nick := p[0]
|
||||
quoted := strings.TrimSpace(p[1])
|
||||
|
||||
// ensure nick looks sane
|
||||
if len(nick) < 16 && len(strings.Fields(nick)) == 1 {
|
||||
quotedLine = strings.TrimSpace(strings.Split(quoted, "\n")[0])
|
||||
ruid = nick
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Someone replied to a native telegram message.
|
||||
quoted := strings.TrimSpace(replyto.Text)
|
||||
quotedLine = strings.TrimSpace(strings.Split(quoted, "\n")[0])
|
||||
}
|
||||
|
||||
// If we have a line, quote it. Otherwise just refer to the nick without a quote.
|
||||
if quotedLine != "" {
|
||||
parts = append(parts, fmt.Sprintf("%s: >%s\n", ruid, quotedLine))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("%s: ", ruid))
|
||||
}
|
||||
}
|
||||
|
||||
// This message contains a sticker.
|
||||
if u.Message.Sticker != nil {
|
||||
emoji := ""
|
||||
if u.Message.Sticker.SetName != "" {
|
||||
emoji += "/" + u.Message.Sticker.SetName
|
||||
}
|
||||
if u.Message.Sticker.Emoji != "" {
|
||||
emoji += "/" + u.Message.Sticker.Emoji
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("<sticker%s>", emoji))
|
||||
}
|
||||
|
||||
// This message contains an animation.
|
||||
if u.Message.Animation != nil {
|
||||
a := u.Message.Animation
|
||||
parts = append(parts, fmt.Sprintf("<uploaded animation: %s >\n", fileURL(a.FileID, "mp4")))
|
||||
}
|
||||
|
||||
// This message contains a document.
|
||||
if u.Message.Document != nil {
|
||||
d := u.Message.Document
|
||||
fnp := strings.Split(d.FileName, ".")
|
||||
ext := "bin"
|
||||
if len(fnp) > 1 {
|
||||
ext = fnp[len(fnp)-1]
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("<uploaded file: %s >\n", fileURL(d.FileID, ext)))
|
||||
}
|
||||
|
||||
// This message contains a photo.
|
||||
if u.Message.Photo != nil {
|
||||
// Multiple entries are for different file sizes, choose the highest quality one.
|
||||
hq := (*u.Message.Photo)[0]
|
||||
for _, p := range *u.Message.Photo {
|
||||
if p.FileSize > hq.FileSize {
|
||||
hq = p
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("<uploaded photo: %s >\n", fileURL(hq.FileID, "jpg")))
|
||||
}
|
||||
|
||||
// This message has some plain text.
|
||||
if text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
|
||||
// Was there anything that we extracted?
|
||||
if len(parts) > 0 {
|
||||
return &telegramPlain{from.String(), strings.Join(parts, " ")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileURL(fid, ext string) string {
|
||||
return flagTeleimgRoot + fid + "." + ext
|
||||
}
|
Loading…
Reference in a new issue