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
diff --git a/WORKSPACE b/WORKSPACE
index 391980c..0d69a6e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1904,3 +1904,21 @@
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",
+)
diff --git a/hswaw/kube/hswaw.jsonnet b/hswaw/kube/hswaw.jsonnet
index 49c9aa3..f550176 100644
--- a/hswaw/kube/hswaw.jsonnet
+++ b/hswaw/kube/hswaw.jsonnet
@@ -19,7 +19,8 @@
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),
},
},
diff --git a/hswaw/kube/teleimg.libsonnet b/hswaw/kube/teleimg.libsonnet
index 58026fb..29f0c2d 100644
--- a/hswaw/kube/teleimg.libsonnet
+++ b/hswaw/kube/teleimg.libsonnet
@@ -6,14 +6,21 @@
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 @@
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,
+ },
+ },
}
diff --git a/personal/q3k/lelegram/BUILD.bazel b/personal/q3k/lelegram/BUILD.bazel
new file mode 100644
index 0000000..87d41b8
--- /dev/null
+++ b/personal/q3k/lelegram/BUILD.bazel
@@ -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}",
+)
diff --git a/personal/q3k/lelegram/irc/BUILD.bazel b/personal/q3k/lelegram/irc/BUILD.bazel
new file mode 100644
index 0000000..b150584
--- /dev/null
+++ b/personal/q3k/lelegram/irc/BUILD.bazel
@@ -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",
+ ],
+)
diff --git a/personal/q3k/lelegram/irc/conn.go b/personal/q3k/lelegram/irc/conn.go
new file mode 100644
index 0000000..a59e754
--- /dev/null
+++ b/personal/q3k/lelegram/irc/conn.go
@@ -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
+ }
+ }
+ }
+}
diff --git a/personal/q3k/lelegram/irc/manager.go b/personal/q3k/lelegram/irc/manager.go
new file mode 100644
index 0000000..4e8365d
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager.go
@@ -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
+ }
+ }
+ }
+}
diff --git a/personal/q3k/lelegram/irc/manager_conns.go b/personal/q3k/lelegram/irc/manager_conns.go
new file mode 100644
index 0000000..6427ec6
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager_conns.go
@@ -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
+}
diff --git a/personal/q3k/lelegram/irc/manager_control.go b/personal/q3k/lelegram/irc/manager_control.go
new file mode 100644
index 0000000..68e9002
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager_control.go
@@ -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)
+ }
+}
diff --git a/personal/q3k/lelegram/irc/manager_event.go b/personal/q3k/lelegram/irc/manager_event.go
new file mode 100644
index 0000000..399915c
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager_event.go
@@ -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)
+ }
+}
diff --git a/personal/q3k/lelegram/main.go b/personal/q3k/lelegram/main.go
new file mode 100644
index 0000000..ef1ce37
--- /dev/null
+++ b/personal/q3k/lelegram/main.go
@@ -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)
+ }
+ }
+ }
+}
diff --git a/personal/q3k/lelegram/telegram.go b/personal/q3k/lelegram/telegram.go
new file mode 100644
index 0000000..a80e76e
--- /dev/null
+++ b/personal/q3k/lelegram/telegram.go
@@ -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
+}