diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..668679613084ec650caff6f7b2ba959184f74fa7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+bridgething
+bridge.conf
+
+*.swp
+.idea/
\ No newline at end of file
diff --git a/cleanup.go b/cleanup.go
new file mode 100644
index 0000000000000000000000000000000000000000..2ebb497637f149ca8e160ac6dace23a4b12d616c
--- /dev/null
+++ b/cleanup.go
@@ -0,0 +1,15 @@
+package main
+
+import "log"
+
+func cleanup() {
+	if botAPI != nil {
+		botAPI.StopReceivingUpdates()
+	}
+
+	if session != nil {
+		if err := session.Close(); err != nil {
+			log.Printf("error closing discord session: %s", err)
+		}
+	}
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000000000000000000000000000000000000..e252e848d9961e155daec5001c9c7aaef50a9bc8
--- /dev/null
+++ b/config.go
@@ -0,0 +1,157 @@
+package main
+
+import (
+	"flag"
+	"github.com/pelletier/go-toml/v2"
+	"log"
+	"os"
+	"strconv"
+)
+
+var (
+	config   conf
+	confPath string
+	parsed   = false
+
+	discordBridge  = make(map[string]bridgeConf)
+	telegramBridge = make(map[int64]bridgeConf)
+)
+
+func init() {
+	flag.StringVar(&confPath, "c", "bridge.conf", "specify location of configuration file")
+}
+
+type conf struct {
+	System   systemConf
+	Telegram telegramConf
+	Discord  discordConf
+	Bridges  struct{ Item []bridgeConf }
+}
+
+type systemConf struct {
+	Verbose        bool
+	DisplaceHeader bool
+}
+
+type telegramConf struct {
+	Token         string
+	NameFormat    string
+	StickerEmoji  bool
+	BypassBacklog bool
+}
+
+type discordConf struct {
+	Token       string
+	NameFormat  string
+	StickerText bool
+}
+
+type bridgeConf struct {
+	Label    string
+	Discord  bridgePlatformConf
+	Telegram bridgePlatformConf
+}
+
+type bridgePlatformConf struct {
+	ID     int
+	Enable bool
+	Join   bool
+	Leave  bool
+	Delete bool
+}
+
+func setupConfig() {
+	if parsed {
+		panic("config already parsed")
+	}
+	defer func() { parsed = true }()
+
+	if file, err := os.Open(confPath); err != nil {
+		if !os.IsNotExist(err) {
+			log.Fatalf("error opening configuration file: %s", err)
+		}
+		if file, err = os.Create(confPath); err != nil {
+			log.Fatalf("error creating configuration file: %s", err)
+		} else {
+			encoder := toml.NewEncoder(file)
+			encoder.SetIndentSymbol("    ")
+			encoder.SetIndentTables(true)
+
+			if err = encoder.Encode(defConf); err != nil {
+				log.Fatalf("error generating default configuration: %s", err)
+			} else {
+				log.Print("default configuration generated")
+				os.Exit(1)
+			}
+		}
+	} else if err = toml.NewDecoder(file).Decode(&config); err != nil {
+		log.Fatalf("error parsing configuration: %s", err)
+	} else {
+		if err = file.Close(); err != nil {
+			log.Fatalf("error closing configuration: %s", err)
+		}
+	}
+
+	for _, c := range config.Bridges.Item {
+		if c.Discord.Enable {
+			discordBridge[strconv.Itoa(c.Discord.ID)] = c
+		}
+		if c.Telegram.Enable {
+			telegramBridge[int64(c.Telegram.ID)] = c
+		}
+	}
+}
+
+var defConf = conf{
+	System: systemConf{
+		Verbose:        false,
+		DisplaceHeader: true,
+	},
+	Telegram: telegramConf{
+		Token:         "INSERT_TOKEN_HERE",
+		NameFormat:    "**$FIRST$SPACE$LAST (@$USER)**",
+		StickerEmoji:  true,
+		BypassBacklog: true,
+	},
+	Discord: discordConf{
+		Token:       "INSERT_TOKEN_HERE",
+		NameFormat:  "$USER#$DISCRIMINATOR",
+		StickerText: true,
+	},
+	Bridges: struct{ Item []bridgeConf }{Item: []bridgeConf{
+		{
+			Label: "Default",
+			Discord: bridgePlatformConf{
+				ID:     0,
+				Enable: true,
+				Join:   true,
+				Leave:  true,
+				Delete: true,
+			},
+			Telegram: bridgePlatformConf{
+				ID:     0,
+				Enable: true,
+				Join:   true,
+				Leave:  true,
+				Delete: true,
+			},
+		},
+		{
+			Label: "Disabled",
+			Discord: bridgePlatformConf{
+				ID:     0,
+				Enable: false,
+				Join:   true,
+				Leave:  true,
+				Delete: true,
+			},
+			Telegram: bridgePlatformConf{
+				ID:     0,
+				Enable: false,
+				Join:   true,
+				Leave:  true,
+				Delete: true,
+			},
+		},
+	}},
+}
diff --git a/discord.go b/discord.go
new file mode 100644
index 0000000000000000000000000000000000000000..db59670b90836599e969c511164b3bf048b7ce63
--- /dev/null
+++ b/discord.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+	"github.com/bwmarrin/discordgo"
+	"log"
+)
+
+var session *discordgo.Session
+
+func init() {
+	discordgo.Logger = func(msgL, _ int, format string, a ...interface{}) {
+		prefix := "discord: "
+		switch msgL {
+		case discordgo.LogError:
+			prefix += "[error] "
+		case discordgo.LogWarning:
+			prefix += "[warning] "
+		case discordgo.LogInformational:
+			prefix += "[informational] "
+		case discordgo.LogDebug:
+			if !config.System.Verbose {
+				return
+			}
+			prefix += "[debug] "
+		}
+		log.Printf(prefix+format, a...)
+	}
+}
+
+func openDiscord() {
+	if s, err := discordgo.New(); err != nil {
+		// this should never happen
+		log.Fatalf("error creating discord session: %s", err)
+	} else {
+		session = s
+	}
+
+	session.Identify.Intents = discordgo.IntentsGuildMessages
+	session.Token = "Bot " + config.Discord.Token
+
+	if err := session.Open(); err != nil {
+		log.Fatalf("error connecting to discord: %s", err)
+	} else {
+		log.Printf("connected to discord as %s#%s (%s)",
+			session.State.User.Username, session.State.User.Discriminator, session.State.User.ID)
+	}
+
+	ready <- struct{}{}
+}
+
+func handleDiscord() {
+	// TODO
+}
diff --git a/go.mod b/go.mod
index 59452587e5d2132c8a269da942cf3bf069c842f2..954d4352cd7ad7cfb2bec04a0665aaf0fc7f413a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,15 @@
 module random.chars.jp/git/bridgething
 
 go 1.17
+
+require (
+	github.com/bwmarrin/discordgo v0.23.2
+	github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
+	github.com/pelletier/go-toml/v2 v2.0.0-beta.3
+)
+
+require (
+	github.com/gorilla/websocket v1.4.0 // indirect
+	github.com/technoweenie/multipartstreamer v1.0.1 // indirect
+	golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000000000000000000000000000000000000..b92f49b5f2533b4a4f6248a9fa307447502e1f7f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,22 @@
+github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4=
+github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
+github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
+github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/pelletier/go-toml/v2 v2.0.0-beta.3 h1:PNCTU4naEJ8mKal97P3A2qDU74QRQGlv4FXiL1XDqi4=
+github.com/pelletier/go-toml/v2 v2.0.0-beta.3/go.mod h1:aNseLYu/uKskg0zpr/kbr2z8yGuWtotWf/0BpGIAL2Y=
+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.7.1-0.20210427113832-6241f9ab9942 h1:t0lM6y/M5IiUZyvbBTcngso8SZEZICH7is9B6g/obVU=
+github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
+github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
+golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA=
+golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..415593f1620800baf1b818605b25170cdfa07cdf
--- /dev/null
+++ b/main.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+	"flag"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+)
+
+var (
+	exec  string
+	rest  bool
+	ready = make(chan struct{})
+)
+
+func main() {
+	flag.Parse()
+
+	setupConfig()
+	go openTelegram()
+	go openDiscord()
+
+	go func() {
+		<-ready
+		<-ready
+		log.Print("sessions ready, starting handlers")
+		go handleDiscord()
+		go handleTelegram()
+	}()
+
+	s := make(chan os.Signal, 1)
+	signal.Notify(s, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+	func() {
+		defer func() { cleanup() }()
+		for {
+			sig := <-s
+			switch sig {
+			case syscall.SIGINT:
+				println()
+				log.Print("shutting down")
+				return
+			default:
+				log.Print("shutting down")
+				return
+			}
+		}
+	}()
+
+	if rest {
+		restart()
+	}
+}
diff --git a/restart.go b/restart.go
new file mode 100644
index 0000000000000000000000000000000000000000..7442e19bd5c4813d0dba400708737de1bb3e4ed5
--- /dev/null
+++ b/restart.go
@@ -0,0 +1,26 @@
+//go:build !windows
+
+package main
+
+import (
+	"log"
+	"os"
+	"syscall"
+)
+
+func restart() {
+	var err error
+	if exec == "" {
+		return
+	}
+
+	if _, err = os.Stat(exec); err != nil {
+		log.Fatalf("error stat exec: %s", err)
+	}
+
+	log.Printf("execve %s", exec)
+	err = syscall.Exec(exec, os.Args, os.Environ())
+	if err != nil {
+		log.Fatalf("error execve: %s", err)
+	}
+}
diff --git a/restart_windows.go b/restart_windows.go
new file mode 100644
index 0000000000000000000000000000000000000000..fa2e49c13383b1337fce156199a8ebc155eadc58
--- /dev/null
+++ b/restart_windows.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+	"log"
+	"os"
+)
+
+func restart() {
+	if exec == "" {
+		return
+	}
+
+	if _, err := os.Stat(executable); err != nil {
+		log.Fatalf("error stat exec: %s", err)
+	}
+	wd, err := os.Getwd()
+	if err != nil {
+		log.Fatalf("error getwd: %s", err)
+	}
+	log.Printf("pwd is %s", wd)
+
+	if _, err = os.StartProcess(executable, []string{}, &os.ProcAttr{
+		Dir:   wd,
+		Env:   nil,
+		Files: []*os.File{os.Stderr, os.Stdin, os.Stdout},
+		Sys:   nil,
+	}); err != nil {
+		log.Fatalf("error start process: %s", err)
+	}
+}
diff --git a/telegram.go b/telegram.go
new file mode 100644
index 0000000000000000000000000000000000000000..3e8ac1b64eea8d47e906088194831915ba0f7f5c
--- /dev/null
+++ b/telegram.go
@@ -0,0 +1,213 @@
+package main
+
+import (
+	"github.com/bwmarrin/discordgo"
+	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
+	"io"
+	"log"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var botAPI *tgbotapi.BotAPI
+
+type botLogger struct{}
+
+func (botLogger) Printf(format string, v ...interface{}) {
+	log.Printf("telegram: "+format, v...)
+}
+func (botLogger) Println(v ...interface{}) {
+	log.Println(append([]interface{}{"telegram:"}, v...)...)
+}
+
+func init() {
+	if err := tgbotapi.SetLogger(botLogger{}); err != nil {
+		panic(err)
+	}
+}
+
+func openTelegram() {
+	if bot, err := tgbotapi.NewBotAPI(config.Telegram.Token); err != nil {
+		log.Fatalf("error connecting to telegram: %s", err)
+	} else {
+		botAPI = bot
+	}
+	log.Printf("connected to telegram as @%s (%v)", botAPI.Self.UserName, botAPI.Self.ID)
+	botAPI.Debug = config.System.Verbose
+
+	ready <- struct{}{}
+}
+
+func handleTelegram() {
+	u := tgbotapi.NewUpdate(0)
+	u.Timeout = 60
+
+	if updates, err := botAPI.GetUpdatesChan(u); err != nil {
+		log.Fatalf("error getting updates: %s", err)
+	} else {
+		if config.Telegram.BypassBacklog {
+			time.Sleep(time.Millisecond * 500)
+			updates.Clear()
+		}
+
+		for update := range updates {
+			respondTelegram(update)
+		}
+	}
+}
+
+var previousCaller = make(map[int64]int)
+
+func setPreviousCaller(cid int64, uid int) bool {
+	if previousCaller[cid] == uid {
+		return true
+	}
+	previousCaller[cid] = uid
+	return false
+}
+
+func respondTelegram(update tgbotapi.Update) {
+	// TODO: cross reply
+	// TODO: sticker object storage
+
+	if update.Message == nil || update.Message.Chat == nil {
+		if update.EditedMessage != nil {
+			// TODO: handle edit
+			return
+		}
+		if config.System.Verbose {
+			log.Printf("got irrelevant update %v", update.UpdateID)
+		}
+		return
+	}
+
+	if update.Message.IsCommand() {
+		message := tgbotapi.NewMessage(update.Message.Chat.ID, "")
+		switch update.Message.Command() {
+		case "id":
+			message.Text = strconv.Itoa(int(update.Message.Chat.ID))
+		default:
+		}
+		if message.Text != "" {
+			message.ReplyToMessageID = update.Message.MessageID
+			if _, err := botAPI.Send(message); err != nil {
+				log.Printf("error responding to command %v, %s", update.Message.Chat.ID, err)
+			}
+			return
+		}
+	}
+
+	var (
+		dc bridgePlatformConf
+		tc bridgePlatformConf
+	)
+	if c, ok := telegramBridge[update.Message.Chat.ID]; !ok {
+		return
+	} else {
+		dc = c.Discord
+		tc = c.Telegram
+	}
+
+	var (
+		username = "unknown"
+		id       = -1
+		dMsgID   = "-1"
+	)
+	if update.Message.From != nil {
+		username = update.Message.From.UserName
+		id = update.Message.From.ID
+	}
+
+	space := ""
+	if update.Message.From.LastName != "" {
+		space = " "
+	}
+	name := ""
+	if !config.System.DisplaceHeader || !setPreviousCaller(update.Message.Chat.ID, update.Message.From.ID) {
+		// TODO: check the discord side for header displace
+		name = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(config.Telegram.NameFormat,
+			"$FIRST", update.Message.From.FirstName),
+			"$LAST", update.Message.From.LastName),
+			"$USER", update.Message.From.UserName),
+			"$SPACE", space)
+		name += "\n"
+	}
+
+	if update.Message.Sticker != nil {
+		var url string
+		if u, err := botAPI.GetFileDirectURL(update.Message.Sticker.FileID); err != nil {
+			msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error getting URL")
+			msg.ReplyToMessageID = update.Message.MessageID
+			log.Printf("error getting URL of %s: %s", update.Message.Sticker.FileID, err)
+			_, _ = botAPI.Send(msg)
+			return
+		} else {
+			url = u
+		}
+
+		var (
+			file *discordgo.File
+			body io.ReadCloser
+		)
+		if resp, err := http.Get(url); err != nil {
+			msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error getting file")
+			msg.ReplyToMessageID = update.Message.MessageID
+			log.Printf("error getting file %s: %s", update.Message.Sticker.FileID, err)
+			_, _ = botAPI.Send(msg)
+			return
+		} else {
+			file = &discordgo.File{
+				Name:        "sticker.webp",
+				ContentType: "image/webp",
+				Reader:      resp.Body,
+			}
+			body = resp.Body
+		}
+
+		content := name
+		if config.Telegram.StickerEmoji {
+			content += update.Message.Sticker.Emoji
+		}
+		if message, err := session.ChannelMessageSendComplex(strconv.Itoa(dc.ID), &discordgo.MessageSend{
+			Content: content,
+			File:    file,
+		}); err != nil {
+			msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error relaying message")
+			msg.ReplyToMessageID = update.Message.MessageID
+			log.Printf("error relaying message %v: %s", update.Message.MessageID, err)
+			_, _ = botAPI.Send(msg)
+			return
+		} else {
+			dMsgID = message.ID
+		}
+		if err := body.Close(); err != nil {
+			log.Printf("error closing request body: %s", err)
+		}
+		log.Printf("T%vM%v -> D%vM%s @%s (%v): %s",
+			tc.ID, update.Message.MessageID, dc.ID, dMsgID, username, id, "Sticker: "+update.Message.Sticker.FileID)
+		return
+	}
+
+	if update.Message.Contact != nil {
+		// TODO: handle contact
+		return
+	}
+
+	if update.Message.Text == "" {
+		return
+	}
+
+	if message, err := session.ChannelMessageSend(strconv.Itoa(dc.ID), name+update.Message.Text); err != nil {
+		msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error relaying message")
+		msg.ReplyToMessageID = update.Message.MessageID
+		log.Printf("error relaying message %v: %s", update.Message.MessageID, err.Error())
+		_, _ = botAPI.Send(msg)
+	} else {
+		dMsgID = message.ID
+	}
+
+	log.Printf("T%vM%v -> D%vM%s @%s (%v): %s",
+		tc.ID, update.Message.MessageID, dc.ID, dMsgID, username, id, update.Message.Text)
+}