feat: add Telegram, Slack and Discord platform support

Add new platform integrations with configuration examples and dependencies:
- Telegram long polling bot support
- Slack Socket Mode WebSocket integration
- Discord Gateway WebSocket connection
- Include required dependencies in go.mod

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chenhg5andClaude committed 2026-02-28 14:56:29 +08:00
1 parent 461a535c92
commit eaec71f8a5
6 files changed
+474 -8

No files matched your search

+40 -8
View File
@@ -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"
+5
View File
@@ -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
)
+18
View File
@@ -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=
+129
View File
@@ -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
}
+149
View File
@@ -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
}
+133
View File
@@ -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
}