diff --git a/.env b/.env index df45f4f..890aa34 100644 --- a/.env +++ b/.env @@ -39,4 +39,3 @@ GEMINI_AUDIO_MODEL=gemini-3.1-flash-tts-preview GEMINI_TRANSCRIPTION_MODEL=gemini-3.1-flash-lite APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE= diff --git a/.env.example b/.env.example index 7ee9f3c..6bb4b5f 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,9 @@ MAIL_USERNAME= MAIL_PASSWORD= MAIL_FROM_ADDRESS= MAIL_FROM_NAME= + +SLACK_BOT_TOKEN= + OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_TEXT_MODEL= diff --git a/.github/workflows/test-features.yml b/.github/workflows/test-features.yml index 593a70c..2c43d07 100644 --- a/.github/workflows/test-features.yml +++ b/.github/workflows/test-features.yml @@ -11,6 +11,7 @@ env: MAIL_FROM_ADDRESS: ${{ secrets.MAIL_FROM_ADDRESS }} MAIL_FROM_NAME: ${{ secrets.MAIL_FROM_NAME }} MAIL_TO: ${{ secrets.MAIL_TO }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} jobs: ubuntu: strategy: diff --git a/bootstrap/providers.go b/bootstrap/providers.go index 5fcabb2..a401650 100644 --- a/bootstrap/providers.go +++ b/bootstrap/providers.go @@ -38,6 +38,7 @@ import ( "github.com/goravel/postgres" "github.com/goravel/redis" "github.com/goravel/s3" + "github.com/goravel/slack" "github.com/goravel/sqlite" "github.com/goravel/sqlserver" "goravel/packages/viewtest" @@ -85,5 +86,6 @@ func Providers() []foundation.ServiceProvider { &gemini.ServiceProvider{}, &viewtest.ServiceProvider{}, &RouteProvider{}, + &slack.ServiceProvider{}, } } diff --git a/config/slack.go b/config/slack.go new file mode 100644 index 0000000..815ee1f --- /dev/null +++ b/config/slack.go @@ -0,0 +1,16 @@ +package config + +import ( + "goravel/app/facades" +) + +func init() { + config := facades.Config() + + config.Add("slack", map[string]any{ + // token is the Slack bot token (xoxb-...). Create one at + // https://api.slack.com/apps — needs the chat:write scope, and + // the bot must be invited to any channel it should post in. + "token": config.Env("SLACK_BOT_TOKEN", ""), + }) +} diff --git a/go.mod b/go.mod index 19477e4..f0842a7 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/goravel/postgres v1.18.0 github.com/goravel/redis v1.18.1-0.20260804080002-ea3e2dcfbdf6 github.com/goravel/s3 v1.18.0 + github.com/goravel/slack v0.0.0-20260819081718-3f42493b34e4 github.com/goravel/sqlite v1.18.0 github.com/goravel/sqlserver v1.18.0 github.com/gorilla/websocket v1.5.3 @@ -184,6 +185,7 @@ require ( github.com/samber/lo v1.53.0 // indirect github.com/samber/slog-common v0.21.0 // indirect github.com/samber/slog-multi v1.8.0 // indirect + github.com/slack-go/slack v0.17.3 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect diff --git a/go.sum b/go.sum index cc68669..a6dcee5 100644 --- a/go.sum +++ b/go.sum @@ -236,6 +236,8 @@ github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5 github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -316,6 +318,8 @@ github.com/goravel/redis v1.18.1-0.20260804080002-ea3e2dcfbdf6 h1:XGd6HrfS9gyi1i github.com/goravel/redis v1.18.1-0.20260804080002-ea3e2dcfbdf6/go.mod h1:2Y6o5g5Pl1tXx6qPf0YAg6r5pXmSSPqvAlJ51GrdZCQ= github.com/goravel/s3 v1.18.0 h1:kdq9jhy0MeWEdpYMk2cGDBjGOr6z/yW3/r8RrdWMagg= github.com/goravel/s3 v1.18.0/go.mod h1:2qntIhQQA1/9678PTlb8rWOoGcLTjXG6/hQ8fCayqi4= +github.com/goravel/slack v0.0.0-20260819081718-3f42493b34e4 h1:IRqDXV89klaNQWSEDWngN70Icoh/uEF4MnYYItgNbaA= +github.com/goravel/slack v0.0.0-20260819081718-3f42493b34e4/go.mod h1:sKIbrIzt8FRPLUgDIXgdzi4Q4K34XP4czWjiw8WETb8= github.com/goravel/sqlite v1.18.0 h1:d9iZHQ9siZXwgOV9Q8tyGr4fbT0Q6LljX/7XcX7fibY= github.com/goravel/sqlite v1.18.0/go.mod h1:uciL6tYnM7FDJagBWQlFzkSPtcaWewxpQvJCmBZmKVg= github.com/goravel/sqlserver v1.18.0 h1:9ZoSZbZUIzpTuvlarWk54d0IjMneugsHNxxYxvtnf4Y= @@ -498,6 +502,8 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= +github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= diff --git a/package_test.go b/package_test.go index 9f18e84..7faa34b 100644 --- a/package_test.go +++ b/package_test.go @@ -189,3 +189,15 @@ func TestInstallAndPublishAndUninstallLocalPackage(t *testing.T) { assert.False(t, file.Contain(path.Bootstrap("providers.go"), "goravel/packages/sms")) assert.False(t, file.Contain(path.Bootstrap("providers.go"), "&sms.ServiceProvider{}")) } + +func TestInstallAndUninstallSlack(t *testing.T) { + assert.NoError(t, facades.Artisan().Call("package:uninstall github.com/goravel/slack@master")) + assert.False(t, file.Contain(path.Bootstrap("providers.go"), "&slack.ServiceProvider{},")) + assert.False(t, file.Contain(path.Bootstrap("providers.go"), "github.com/goravel/slack")) + assert.False(t, file.Exists(path.Config("slack.go"))) + + assert.NoError(t, facades.Artisan().Call("package:install github.com/goravel/slack@master")) + assert.True(t, file.Contain(path.Bootstrap("providers.go"), "&slack.ServiceProvider{},")) + assert.True(t, file.Contain(path.Bootstrap("providers.go"), "github.com/goravel/slack")) + assert.True(t, file.Exists(path.Config("slack.go"))) +} diff --git a/tests/feature/notification_test.go b/tests/feature/notification_test.go index a622881..9b72995 100644 --- a/tests/feature/notification_test.go +++ b/tests/feature/notification_test.go @@ -24,6 +24,7 @@ import ( "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/path" "github.com/goravel/framework/support/str" + slackcontracts "github.com/goravel/slack/contracts" "goravel/app/facades" "goravel/app/models" @@ -57,7 +58,7 @@ func (s *NotificationTestSuite) TestFacadeResolves() { s.NotNil(facades.Notification()) s.NotNil(facades.Notification().Channel(notification.ChannelDatabase)) s.NotNil(facades.Notification().Channel(notification.ChannelMail)) - s.Nil(facades.Notification().Channel("slack")) + s.NotNil(facades.Notification().Channel(slackcontracts.ChannelName)) } func (s *NotificationTestSuite) TestSendDatabaseNotification() { @@ -310,9 +311,9 @@ func (s *NotificationTestSuite) TestCustomChannel() { } func (s *NotificationTestSuite) TestSendToUnknownChannel() { - err := facades.Notification().Route("slack", "route").NotifyNow(&unknownChannelNotification{}) + err := facades.Notification().Route("unknown-channel", "route").NotifyNow(&unknownChannelNotification{}) s.Error(err) - s.ErrorIs(err, frameworkerrors.NotificationChannelNotFound.Args("slack")) + s.ErrorIs(err, frameworkerrors.NotificationChannelNotFound.Args("unknown-channel")) } func (s *NotificationTestSuite) TestSendMailNotification() { @@ -1016,7 +1017,7 @@ func (r *chainedViaNotification) ToDatabase(notifiable notification.Notifiable) type unknownChannelNotification struct{} func (r *unknownChannelNotification) Via(notifiable notification.Notifiable) []string { - return []string{"slack"} + return []string{"unknown-channel"} } // mailRoutableNotifiable implements MailRoutable with a fixed recipient. diff --git a/tests/feature/slack_test.go b/tests/feature/slack_test.go new file mode 100644 index 0000000..5ef4136 --- /dev/null +++ b/tests/feature/slack_test.go @@ -0,0 +1,88 @@ +package feature + +import ( + "testing" + + "github.com/goravel/framework/contracts/notification" + slackcontracts "github.com/goravel/slack/contracts" + "github.com/stretchr/testify/suite" + + "goravel/app/facades" + "goravel/tests" +) + +type SlackTestSuite struct { + suite.Suite + tests.TestCase +} + +func TestSlackTestSuite(t *testing.T) { + suite.Run(t, &SlackTestSuite{}) +} + +// requireSlack skips the test unless a bot token is configured, and returns +// the channel to post to. Mirrors the mail live-test pattern: skipped locally, +// exercised for real in CI when secrets are set. +func (s *SlackTestSuite) requireSlack() string { + if facades.Config().GetString("slack.token") == "" { + s.T().Skip("skipping Slack test: set SLACK_BOT_TOKEN to run against a real Slack workspace") + } + return "general" +} + +func (s *SlackTestSuite) TestSlackChannelRegistered() { + s.NotNil(facades.Notification().Channel(slackcontracts.ChannelName)) +} + +func (s *SlackTestSuite) TestSendSlackTextNotification() { + channel := s.requireSlack() + + err := facades.Notification(). + Route(slackcontracts.ChannelName, channel). + NotifyNow(&slackTextNotification{}) + s.NoErrorf(err, "sending to channel %q", channel) +} + +func (s *SlackTestSuite) TestSendSlackRichNotification() { + channel := s.requireSlack() + + n := &slackRichNotification{msg: slackcontracts.Message{ + Text: "Goravel Slack integration test — rich delivery", + Attachments: []slackcontracts.Attachment{ + { + Title: "Details", Text: "Invoice #123", Color: "good", + Fields: []slackcontracts.Field{ + {Title: "Amount", Value: "$99.00", Short: true}, + {Title: "Customer", Value: "Acme Corp", Short: true}, + }, + }, + }, + }} + + err := facades.Notification(). + Route(slackcontracts.ChannelName, channel). + NotifyNow(n) + s.NoErrorf(err, "sending to channel %q", channel) +} + +type slackTextNotification struct{} + +func (r *slackTextNotification) Via(_ notification.Notifiable) []string { + return []string{slackcontracts.ChannelName} +} + +func (r *slackTextNotification) ToSlack(_ notification.Notifiable) slackcontracts.Message { + return slackcontracts.Message{Text: "Goravel Slack integration test — text delivery"} +} + +type slackRichNotification struct { + msg slackcontracts.Message +} + +func (r *slackRichNotification) Via(_ notification.Notifiable) []string { + return []string{slackcontracts.ChannelName} +} + +func (r *slackRichNotification) ToSlack(_ notification.Notifiable) slackcontracts.Message { + return r.msg +}