diff --git a/config.example.toml b/config.example.toml index d10cb87..ee237da 100644 --- a/config.example.toml +++ b/config.example.toml @@ -54,6 +54,44 @@ app_secret = "your-feishu-app-secret" # client_id = "your-dingtalk-client-id" # client_secret = "your-dingtalk-client-secret" +# Telegram (uncomment to enable) +# 1. Message @BotFather on Telegram, send /newbot to create a bot +# 2. Copy the bot token below +# Connection: Long polling (no public URL needed) + +# [[projects.platforms]] +# type = "telegram" +# +# [projects.platforms.options] +# token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + +# Slack (uncomment to enable) +# 1. Create an app at https://api.slack.com/apps +# 2. Enable Socket Mode (Settings > Socket Mode) +# 3. Subscribe to bot events: message.channels, message.im (Event Subscriptions) +# 4. Install app to workspace, copy Bot Token (xoxb-...) and App Token (xapp-...) +# Connection: Socket Mode WebSocket (no public URL needed) + +# [[projects.platforms]] +# type = "slack" +# +# [projects.platforms.options] +# bot_token = "xoxb-your-bot-token" +# app_token = "xapp-your-app-level-token" + +# Discord (uncomment to enable) +# 1. Create an app at https://discord.com/developers/applications +# 2. Under "Bot", create a bot and copy the token +# 3. Enable "Message Content Intent" under Privileged Gateway Intents +# 4. Invite bot to server with OAuth2 URL Generator (scopes: bot; permissions: Send Messages) +# Connection: Gateway WebSocket (no public URL needed) + +# [[projects.platforms]] +# type = "discord" +# +# [projects.platforms.options] +# token = "your-discord-bot-token" + # ============================================================================= # Project 2 (uncomment to enable) # ============================================================================= @@ -68,14 +106,8 @@ app_secret = "your-feishu-app-secret" # work_dir = "/path/to/frontend" # mode = "auto" # -# # DingTalk -# # 1. Create an app at https://open-dev.dingtalk.com -# # 2. Create a Bot, select Stream mode -# # Note: No WebSocket URL needed — the Stream SDK handles the connection. -# # [[projects.platforms]] -# type = "dingtalk" +# type = "telegram" # # [projects.platforms.options] -# client_id = "your-dingtalk-client-id" -# client_secret = "your-dingtalk-client-secret" +# token = "your-telegram-bot-token" diff --git a/go.mod b/go.mod index 7f1daa5..5dcf3e2 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,17 @@ go 1.24.2 require ( github.com/BurntSushi/toml v1.6.0 + github.com/bwmarrin/discordgo v0.29.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 + github.com/slack-go/slack v0.15.0 ) require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect ) diff --git a/go.sum b/go.sum index 48e0113..15ef1e2 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,20 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -16,6 +25,9 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= 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/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0= +github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -23,18 +35,24 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/platform/discord/discord.go b/platform/discord/discord.go new file mode 100644 index 0000000..3a5bde1 --- /dev/null +++ b/platform/discord/discord.go @@ -0,0 +1,129 @@ +package discord + +import ( + "context" + "fmt" + "log/slog" + + "github.com/chenhg5/cc-connect/core" + + "github.com/bwmarrin/discordgo" +) + +func init() { + core.RegisterPlatform("discord", New) +} + +const maxDiscordLen = 2000 + +type replyContext struct { + channelID string + messageID string +} + +type Platform struct { + token string + session *discordgo.Session + handler core.MessageHandler + botID string +} + +func New(opts map[string]any) (core.Platform, error) { + token, _ := opts["token"].(string) + if token == "" { + return nil, fmt.Errorf("discord: token is required") + } + return &Platform{token: token}, nil +} + +func (p *Platform) Name() string { return "discord" } + +func (p *Platform) Start(handler core.MessageHandler) error { + p.handler = handler + + session, err := discordgo.New("Bot " + p.token) + if err != nil { + return fmt.Errorf("discord: create session: %w", err) + } + p.session = session + + session.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsDirectMessages | discordgo.IntentMessageContent + + session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + p.botID = r.User.ID + slog.Info("discord: connected", "bot", r.User.Username+"#"+r.User.Discriminator) + }) + + session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author.Bot || m.Author.ID == p.botID { + return + } + + slog.Debug("discord: message received", "user", m.Author.Username, "channel", m.ChannelID) + + sessionKey := fmt.Sprintf("discord:%s:%s", m.ChannelID, m.Author.ID) + + msg := &core.Message{ + SessionKey: sessionKey, + Platform: "discord", + UserID: m.Author.ID, + UserName: m.Author.Username, + Content: m.Content, + ReplyCtx: replyContext{channelID: m.ChannelID, messageID: m.ID}, + } + + p.handler(p, msg) + }) + + if err := session.Open(); err != nil { + return fmt.Errorf("discord: open gateway: %w", err) + } + + return nil +} + +func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { + rc, ok := rctx.(replyContext) + if !ok { + return fmt.Errorf("discord: invalid reply context type %T", rctx) + } + + // Discord has a 2000 char limit per message + for len(content) > 0 { + chunk := content + if len(chunk) > maxDiscordLen { + // Try to split at a newline + cut := maxDiscordLen + if idx := lastIndexBefore(content, '\n', cut); idx > 0 { + cut = idx + 1 + } + chunk = content[:cut] + content = content[cut:] + } else { + content = "" + } + + ref := &discordgo.MessageReference{MessageID: rc.messageID} + _, err := p.session.ChannelMessageSendReply(rc.channelID, chunk, ref) + if err != nil { + return fmt.Errorf("discord: send: %w", err) + } + } + return nil +} + +func (p *Platform) Stop() error { + if p.session != nil { + return p.session.Close() + } + return nil +} + +func lastIndexBefore(s string, b byte, before int) int { + for i := before - 1; i >= 0; i-- { + if s[i] == b { + return i + } + } + return -1 +} diff --git a/platform/slack/slack.go b/platform/slack/slack.go new file mode 100644 index 0000000..eff72ed --- /dev/null +++ b/platform/slack/slack.go @@ -0,0 +1,149 @@ +package slack + +import ( + "context" + "fmt" + "log/slog" + + "github.com/chenhg5/cc-connect/core" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +func init() { + core.RegisterPlatform("slack", New) +} + +type replyContext struct { + channel string + timestamp string // thread_ts for threading replies +} + +type Platform struct { + botToken string + appToken string + client *slack.Client + socket *socketmode.Client + handler core.MessageHandler + cancel context.CancelFunc +} + +func New(opts map[string]any) (core.Platform, error) { + botToken, _ := opts["bot_token"].(string) + appToken, _ := opts["app_token"].(string) + if botToken == "" || appToken == "" { + return nil, fmt.Errorf("slack: bot_token and app_token are required") + } + return &Platform{ + botToken: botToken, + appToken: appToken, + }, nil +} + +func (p *Platform) Name() string { return "slack" } + +func (p *Platform) Start(handler core.MessageHandler) error { + p.handler = handler + + p.client = slack.New(p.botToken, + slack.OptionAppLevelToken(p.appToken), + ) + p.socket = socketmode.New(p.client) + + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + + go func() { + for { + select { + case <-ctx.Done(): + return + case evt := <-p.socket.Events: + p.handleEvent(evt) + } + } + }() + + go func() { + if err := p.socket.RunContext(ctx); err != nil { + slog.Error("slack: socket mode error", "error", err) + } + }() + + slog.Info("slack: socket mode connected") + return nil +} + +func (p *Platform) handleEvent(evt socketmode.Event) { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + data, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + return + } + p.socket.Ack(*evt.Request) + + if data.Type == slackevents.CallbackEvent { + switch ev := data.InnerEvent.Data.(type) { + case *slackevents.MessageEvent: + if ev.BotID != "" || ev.User == "" { + return + } + + slog.Debug("slack: message received", "user", ev.User, "channel", ev.Channel) + + sessionKey := fmt.Sprintf("slack:%s:%s", ev.Channel, ev.User) + + // Use the message timestamp as thread_ts so replies are threaded + ts := ev.TimeStamp + + msg := &core.Message{ + SessionKey: sessionKey, + Platform: "slack", + UserID: ev.User, + UserName: ev.User, + Content: ev.Text, + ReplyCtx: replyContext{channel: ev.Channel, timestamp: ts}, + } + + p.handler(p, msg) + } + } + + case socketmode.EventTypeConnecting: + slog.Debug("slack: connecting...") + case socketmode.EventTypeConnected: + slog.Info("slack: connected") + case socketmode.EventTypeConnectionError: + slog.Error("slack: connection error") + } +} + +func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { + rc, ok := rctx.(replyContext) + if !ok { + return fmt.Errorf("slack: invalid reply context type %T", rctx) + } + + opts := []slack.MsgOption{ + slack.MsgOptionText(content, false), + } + if rc.timestamp != "" { + opts = append(opts, slack.MsgOptionTS(rc.timestamp)) + } + + _, _, err := p.client.PostMessageContext(ctx, rc.channel, opts...) + if err != nil { + return fmt.Errorf("slack: send: %w", err) + } + return nil +} + +func (p *Platform) Stop() error { + if p.cancel != nil { + p.cancel() + } + return nil +} diff --git a/platform/telegram/telegram.go b/platform/telegram/telegram.go new file mode 100644 index 0000000..fb03827 --- /dev/null +++ b/platform/telegram/telegram.go @@ -0,0 +1,133 @@ +package telegram + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/chenhg5/cc-connect/core" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func init() { + core.RegisterPlatform("telegram", New) +} + +type replyContext struct { + chatID int64 + messageID int +} + +type Platform struct { + token string + bot *tgbotapi.BotAPI + handler core.MessageHandler + cancel context.CancelFunc +} + +func New(opts map[string]any) (core.Platform, error) { + token, _ := opts["token"].(string) + if token == "" { + return nil, fmt.Errorf("telegram: token is required") + } + return &Platform{token: token}, nil +} + +func (p *Platform) Name() string { return "telegram" } + +func (p *Platform) Start(handler core.MessageHandler) error { + p.handler = handler + + bot, err := tgbotapi.NewBotAPI(p.token) + if err != nil { + return fmt.Errorf("telegram: auth failed: %w", err) + } + p.bot = bot + + slog.Info("telegram: connected", "bot", bot.Self.UserName) + + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + + u := tgbotapi.NewUpdate(0) + u.Timeout = 30 + updates := bot.GetUpdatesChan(u) + + go func() { + for { + select { + case <-ctx.Done(): + return + case update := <-updates: + if update.Message == nil || update.Message.Text == "" { + continue + } + + msg := update.Message + text := msg.Text + + // Strip bot mention in group chats: "/cmd@botname args" → "/cmd args" + if p.bot.Self.UserName != "" { + text = strings.Replace(text, "@"+p.bot.Self.UserName, "", 1) + } + + userName := msg.From.UserName + if userName == "" { + userName = strings.TrimSpace(msg.From.FirstName + " " + msg.From.LastName) + } + + sessionKey := fmt.Sprintf("telegram:%d:%d", msg.Chat.ID, msg.From.ID) + + coreMsg := &core.Message{ + SessionKey: sessionKey, + Platform: "telegram", + UserID: strconv.FormatInt(msg.From.ID, 10), + UserName: userName, + Content: text, + ReplyCtx: replyContext{chatID: msg.Chat.ID, messageID: msg.MessageID}, + } + + slog.Debug("telegram: message received", "user", userName, "chat", msg.Chat.ID) + p.handler(p, coreMsg) + } + } + }() + + return nil +} + +func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { + rc, ok := rctx.(replyContext) + if !ok { + return fmt.Errorf("telegram: invalid reply context type %T", rctx) + } + + reply := tgbotapi.NewMessage(rc.chatID, content) + reply.ReplyToMessageID = rc.messageID + reply.ParseMode = tgbotapi.ModeMarkdown + + if _, err := p.bot.Send(reply); err != nil { + // Markdown parse failure → retry as plain text + if strings.Contains(err.Error(), "can't parse") { + reply.ParseMode = "" + _, err = p.bot.Send(reply) + } + if err != nil { + return fmt.Errorf("telegram: send: %w", err) + } + } + return nil +} + +func (p *Platform) Stop() error { + if p.cancel != nil { + p.cancel() + } + if p.bot != nil { + p.bot.StopReceivingUpdates() + } + return nil +}