Skip to content

Commit 4d6a19e

Browse files
committed
feat: set icon method for Bolt apps
1 parent 5ee9e10 commit 4d6a19e

File tree

6 files changed

+69
-27
lines changed

6 files changed

+69
-27
lines changed

internal/api/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type AppsClient interface {
5959
GetPresignedS3PostParams(ctx context.Context, token string, appID string) (GenerateS3PresignedPostResult, error)
6060
Host() string
6161
Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error)
62+
IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error)
6263
RequestAppApproval(ctx context.Context, token string, appID string, teamID string, reason string, scopes string, outgoingDomains []string) (AppsApprovalsRequestsCreateResult, error)
6364
SetHost(host string)
6465
UninstallApp(ctx context.Context, token string, appID, teamID string) error

internal/api/icon.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import (
3535

3636
const (
3737
appIconMethod = "apps.hosted.icon"
38+
// AppIconSetMethod is the API method for setting app icons for non-hosted apps.
39+
AppIconSetMethod = "apps.icon.set"
3840
)
3941

4042
// IconResult details to be saved
@@ -48,6 +50,16 @@ type iconResponse struct {
4850

4951
// Icon updates a Slack App's icon
5052
func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
53+
return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconMethod, "file")
54+
}
55+
56+
// IconSet sets a Slack App's icon using the apps.icon.set API method.
57+
func (c *Client) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
58+
return c.uploadIcon(ctx, fs, token, appID, iconFilePath, AppIconSetMethod, "icon")
59+
}
60+
61+
// uploadIcon uploads an icon to the given API method.
62+
func (c *Client) uploadIcon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath, apiMethod, fileFieldName string) (IconResult, error) {
5163
var (
5264
iconBytes []byte
5365
err error
@@ -81,7 +93,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa
8193

8294
var part io.Writer
8395
h := make(textproto.MIMEHeader)
84-
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", iconStat.Name()))
96+
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileFieldName, iconStat.Name()))
8597
h.Set("Content-Type", http.DetectContentType(iconBytes))
8698
part, err = writer.CreatePart(h)
8799
if err != nil {
@@ -101,7 +113,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa
101113
writer.Close()
102114

103115
var sURL *url.URL
104-
sURL, err = url.Parse(c.host + "/api/" + appIconMethod)
116+
sURL, err = url.Parse(c.host + "/api/" + apiMethod)
105117
if err != nil {
106118
return IconResult{}, err
107119
}

internal/api/icon_mock.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import (
2121
)
2222

2323
func (m *APIMock) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
24-
args := m.Called(ctx, fs, token, iconFilePath)
24+
args := m.Called(ctx, fs, token, appID, iconFilePath)
25+
return args.Get(0).(IconResult), args.Error(1)
26+
}
27+
28+
func (m *APIMock) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
29+
args := m.Called(ctx, fs, token, appID, iconFilePath)
2530
return args.Get(0).(IconResult), args.Error(1)
2631
}

internal/api/icon_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ func TestClient_IconErrorWrongFile(t *testing.T) {
6666
require.Contains(t, err.Error(), "unknown format")
6767
}
6868

69+
func TestClient_IconSetSuccess(t *testing.T) {
70+
ctx := slackcontext.MockContext(t.Context())
71+
fs := afero.NewMemMapFs()
72+
73+
myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})
74+
75+
for x := range 100 {
76+
for y := range 100 {
77+
c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255}
78+
myimage.Set(x, y, c)
79+
}
80+
}
81+
myfile, _ := fs.Create(imgFile)
82+
err := png.Encode(myfile, myimage)
83+
require.NoError(t, err)
84+
c, teardown := NewFakeClient(t, FakeClientParams{
85+
ExpectedMethod: AppIconSetMethod,
86+
Response: `{"ok":true}`,
87+
})
88+
defer teardown()
89+
_, err = c.IconSet(ctx, fs, "token", "12345", imgFile)
90+
require.NoError(t, err)
91+
}
92+
6993
func TestClient_IconSuccess(t *testing.T) {
7094
ctx := slackcontext.MockContext(t.Context())
7195
fs := afero.NewMemMapFs()

internal/experiment/experiment.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ const (
3939
// Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes.
4040
Sandboxes Experiment = "sandboxes"
4141

42+
// SetIcon experiment enables icon upload for non-hosted apps.
43+
SetIcon Experiment = "set-icon"
44+
4245
// Templates experiment brings more agent templates to the create command.
4346
Templates Experiment = "templates"
4447
)
@@ -49,6 +52,7 @@ var AllExperiments = []Experiment{
4952
Lipgloss,
5053
Placeholder,
5154
Sandboxes,
55+
SetIcon,
5256
Templates,
5357
}
5458

internal/pkg/apps/install.go

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/opentracing/opentracing-go"
2525
"github.com/slackapi/slack-cli/internal/api"
2626
"github.com/slackapi/slack-cli/internal/config"
27+
"github.com/slackapi/slack-cli/internal/experiment"
2728
"github.com/slackapi/slack-cli/internal/pkg/manifest"
2829
"github.com/slackapi/slack-cli/internal/shared"
2930
"github.com/slackapi/slack-cli/internal/shared/types"
@@ -521,30 +522,25 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran
521522
return app, result, installState, err
522523
}
523524

524-
//
525-
// TODO: Currently, cannot update the icon if app is not hosted.
526-
//
527-
// upload icon, default to icon.png
528-
// var iconPath = slackYaml.Icon
529-
// if iconPath == "" {
530-
// if _, err := os.Stat("icon.png"); !os.IsNotExist(err) {
531-
// iconPath = "icon.png"
532-
// }
533-
// }
534-
// if iconPath != "" {
535-
// clients.IO.PrintDebug(ctx, "uploading icon")
536-
// err = updateIcon(ctx, clients, iconPath, env.AppID, token)
537-
// if err != nil {
538-
// clients.IO.PrintError(ctx, "An error occurred updating the Icon", err)
539-
// }
540-
// // Save a md5 hash of the icon in environments.yaml
541-
// var iconHash string
542-
// iconHash, err = getIconHash(iconPath)
543-
// if err != nil {
544-
// return env, api.DeveloperAppInstallResult{}, err
545-
// }
546-
// env.IconHash = iconHash
547-
// }
525+
// upload icon for non-hosted apps (gated behind set-icon experiment)
526+
if clients.Config.WithExperimentOn(experiment.SetIcon) {
527+
var iconPath = slackManifest.Icon
528+
if iconPath == "" {
529+
if _, err := os.Stat("icon.png"); !os.IsNotExist(err) {
530+
iconPath = "icon.png"
531+
}
532+
}
533+
if iconPath != "" {
534+
clients.IO.PrintDebug(ctx, "uploading icon")
535+
_, iconErr := clients.API().IconSet(ctx, clients.Fs, token, app.AppID, iconPath)
536+
if iconErr != nil {
537+
clients.IO.PrintDebug(ctx, "icon error: %s", iconErr)
538+
_, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Error updating app icon: %s", iconErr)))
539+
} else {
540+
_, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Updated app icon: %s", iconPath)))
541+
}
542+
}
543+
}
548544

549545
// update config with latest yaml hash
550546
// env.Hash = slackYaml.Hash

0 commit comments

Comments
 (0)