agent-slack 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1317,6 +1317,32 @@ async function resolveChannelId(client, input) {
1317
1317
  }
1318
1318
  throw new Error(`Could not resolve channel name: #${name}`);
1319
1319
  }
1320
+ async function resolveChannelName(client, channelId) {
1321
+ try {
1322
+ const resp = await client.api("conversations.info", { channel: channelId });
1323
+ const channel = isRecord5(resp) ? resp.channel : null;
1324
+ if (!isRecord5(channel)) {
1325
+ return channelId;
1326
+ }
1327
+ if (channel.is_im) {
1328
+ const userId = getString(channel.user);
1329
+ if (userId) {
1330
+ try {
1331
+ const userResp = await client.api("users.info", { user: userId });
1332
+ const user = isRecord5(userResp) ? userResp.user : null;
1333
+ const profile = isRecord5(user) ? user.profile : null;
1334
+ if (isRecord5(profile)) {
1335
+ return getString(profile.display_name) || getString(profile.real_name) || channelId;
1336
+ }
1337
+ } catch {}
1338
+ }
1339
+ return channelId;
1340
+ }
1341
+ return getString(channel.name) || channelId;
1342
+ } catch {
1343
+ return channelId;
1344
+ }
1345
+ }
1320
1346
 
1321
1347
  // src/slack/client.ts
1322
1348
  import { WebClient } from "@slack/web-api";
@@ -3369,6 +3395,1471 @@ async function reactOnTarget(input) {
3369
3395
  return { ok: true };
3370
3396
  }
3371
3397
 
3398
+ // src/cli/draft-server.ts
3399
+ import { createServer } from "node:http";
3400
+ import { exec } from "node:child_process";
3401
+ function openDraftEditor(config) {
3402
+ return new Promise((resolve3, reject) => {
3403
+ let settled = false;
3404
+ const server = createServer(async (req, res) => {
3405
+ if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) {
3406
+ const html = buildEditorHtml(config);
3407
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
3408
+ res.end(html);
3409
+ return;
3410
+ }
3411
+ if (req.method === "POST" && req.url === "/send") {
3412
+ try {
3413
+ const body = await readBody(req);
3414
+ const data = JSON.parse(body);
3415
+ if (typeof data.text !== "string" || !data.text.trim()) {
3416
+ res.writeHead(400, { "Content-Type": "application/json" });
3417
+ res.end(JSON.stringify({ ok: false, error: "text is required" }));
3418
+ return;
3419
+ }
3420
+ const sendResult = await config.onSend(data.text);
3421
+ res.writeHead(200, { "Content-Type": "application/json" });
3422
+ res.end(JSON.stringify({ ok: true, ts: sendResult.ts }));
3423
+ settled = true;
3424
+ resolve3({ sent: true, text: data.text });
3425
+ setTimeout(() => server.close(), 300);
3426
+ } catch (err) {
3427
+ res.writeHead(500, { "Content-Type": "application/json" });
3428
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
3429
+ }
3430
+ return;
3431
+ }
3432
+ if (req.method === "POST" && req.url === "/cancel") {
3433
+ res.writeHead(200, { "Content-Type": "application/json" });
3434
+ res.end(JSON.stringify({ ok: true }));
3435
+ settled = true;
3436
+ resolve3({ cancelled: true });
3437
+ setTimeout(() => server.close(), 300);
3438
+ return;
3439
+ }
3440
+ res.writeHead(404);
3441
+ res.end("Not found");
3442
+ });
3443
+ server.on("error", (err) => {
3444
+ if (!settled) {
3445
+ reject(err);
3446
+ }
3447
+ });
3448
+ server.on("close", () => {
3449
+ clearTimeout(idleTimeout);
3450
+ if (!settled) {
3451
+ settled = true;
3452
+ resolve3({ cancelled: true });
3453
+ }
3454
+ });
3455
+ const idleTimeout = setTimeout(() => {
3456
+ if (!settled) {
3457
+ server.close();
3458
+ }
3459
+ }, 30 * 60 * 1000);
3460
+ server.listen(0, "127.0.0.1", () => {
3461
+ const addr = server.address();
3462
+ const port = typeof addr === "object" && addr ? addr.port : 0;
3463
+ const url = `http://127.0.0.1:${port}`;
3464
+ process.stderr.write(`Draft editor: ${url}
3465
+ `);
3466
+ openBrowser(url);
3467
+ });
3468
+ });
3469
+ }
3470
+ function readBody(req) {
3471
+ return new Promise((resolve3, reject) => {
3472
+ const chunks = [];
3473
+ req.on("data", (chunk) => chunks.push(chunk));
3474
+ req.on("end", () => resolve3(Buffer.concat(chunks).toString()));
3475
+ req.on("error", reject);
3476
+ });
3477
+ }
3478
+ function openBrowser(url) {
3479
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3480
+ exec(`${cmd} "${url}"`, () => {});
3481
+ }
3482
+ function buildSlackThreadUrl(config) {
3483
+ if (!config.workspaceUrl || !config.channelId || !config.threadTs) {
3484
+ return null;
3485
+ }
3486
+ const tsNoDot = config.threadTs.replace(".", "");
3487
+ return `${config.workspaceUrl.replace(/\/$/, "")}/archives/${config.channelId}/p${tsNoDot}`;
3488
+ }
3489
+ function extractWorkspaceName(url) {
3490
+ if (!url) {
3491
+ return null;
3492
+ }
3493
+ try {
3494
+ const host = new URL(url).hostname;
3495
+ const parts = host.split(".");
3496
+ if (parts.length >= 3 && parts.at(-2) === "slack") {
3497
+ return parts.slice(0, -2).join(".");
3498
+ }
3499
+ return host;
3500
+ } catch {
3501
+ return null;
3502
+ }
3503
+ }
3504
+ function buildEditorHtml(config) {
3505
+ const threadUrl = buildSlackThreadUrl(config);
3506
+ const workspaceName = extractWorkspaceName(config.workspaceUrl);
3507
+ const injectedConfig = JSON.stringify({
3508
+ channelName: config.channelName,
3509
+ channelId: config.channelId || null,
3510
+ workspaceUrl: config.workspaceUrl || null,
3511
+ workspaceName,
3512
+ threadTs: config.threadTs || null,
3513
+ threadUrl,
3514
+ initialText: config.initialText || ""
3515
+ });
3516
+ return EDITOR_HTML.replace("__DRAFT_CONFIG__", injectedConfig.replace(/</g, "\\u003c"));
3517
+ }
3518
+ var EDITOR_HTML = `<!DOCTYPE html>
3519
+ <html lang="en">
3520
+ <head>
3521
+ <meta charset="UTF-8">
3522
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3523
+ <title>Draft Message — Agent Slack</title>
3524
+ <link rel="preconnect" href="https://fonts.googleapis.com">
3525
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3526
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
3527
+ <style>
3528
+ :root {
3529
+ --bg: #1a1d21;
3530
+ --surface: #222529;
3531
+ --surface-raised: #2c2d31;
3532
+ --border: #393b3f;
3533
+ --border-focus: #1264a3;
3534
+ --text: #d1d2d3;
3535
+ --text-secondary: #ababad;
3536
+ --text-muted: #696a6d;
3537
+ --green: #007a5a;
3538
+ --green-hover: #148567;
3539
+ --red: #e01e5a;
3540
+ --blue: #1264a3;
3541
+ --blue-link: #1d9bd1;
3542
+ --code-bg: #1a1d21;
3543
+ --blockquote-border: #616061;
3544
+ --toolbar-active: rgba(29,155,209,0.15);
3545
+ }
3546
+
3547
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3548
+
3549
+ body {
3550
+ font-family: Slack-Lato, Lato, appleLogo, sans-serif;
3551
+ background: var(--bg);
3552
+ color: var(--text);
3553
+ display: flex;
3554
+ align-items: flex-start;
3555
+ justify-content: center;
3556
+ min-height: 100vh;
3557
+ padding: 40px 24px;
3558
+ }
3559
+
3560
+ .page {
3561
+ width: 100%;
3562
+ max-width: 720px;
3563
+ display: flex;
3564
+ flex-direction: column;
3565
+ align-items: center;
3566
+ animation: fadeIn 0.18s ease-out;
3567
+ }
3568
+
3569
+ @keyframes fadeIn {
3570
+ from { opacity: 0; transform: translateY(6px); }
3571
+ to { opacity: 1; transform: translateY(0); }
3572
+ }
3573
+
3574
+ /* ── Branding ── */
3575
+ .brand {
3576
+ position: relative;
3577
+ text-align: center;
3578
+ margin-bottom: 20px;
3579
+ user-select: none;
3580
+ }
3581
+ .brand-name {
3582
+ font-size: 17px;
3583
+ font-weight: 900;
3584
+ letter-spacing: -0.3px;
3585
+ color: var(--text);
3586
+ }
3587
+ .brand-byline {
3588
+ display: flex;
3589
+ align-items: center;
3590
+ gap: 6px;
3591
+ margin-top: 2px;
3592
+ justify-content: center;
3593
+ font-family: 'Inter', sans-serif;
3594
+ font-size: 10px;
3595
+ font-weight: 500;
3596
+ color: rgba(255,255,255,0.5);
3597
+ }
3598
+ .brand-byline a {
3599
+ color: rgba(255,255,255,0.6);
3600
+ text-decoration: none;
3601
+ }
3602
+ .brand-byline a:hover {
3603
+ color: rgba(255,255,255,0.8);
3604
+ }
3605
+ .brand-sub {
3606
+ font-size: 11px;
3607
+ font-weight: 600;
3608
+ letter-spacing: 1.5px;
3609
+ text-transform: uppercase;
3610
+ color: var(--text-muted);
3611
+ margin-top: 8px;
3612
+ }
3613
+ .brand-byline img {
3614
+ width: 12px;
3615
+ height: 12px;
3616
+ opacity: 0.6;
3617
+ vertical-align: -1px;
3618
+ }
3619
+
3620
+ /* ── Context bar ── */
3621
+ .context-bar {
3622
+ width: 100%;
3623
+ display: flex;
3624
+ align-items: center;
3625
+ gap: 6px;
3626
+ padding: 0 4px;
3627
+ margin-bottom: 10px;
3628
+ font-size: 13px;
3629
+ color: var(--text-secondary);
3630
+ }
3631
+
3632
+ .context-bar .hash-icon {
3633
+ width: 15px;
3634
+ height: 15px;
3635
+ color: var(--text-muted);
3636
+ flex-shrink: 0;
3637
+ }
3638
+
3639
+ .context-bar .channel {
3640
+ font-weight: 700;
3641
+ color: var(--text);
3642
+ }
3643
+
3644
+ .context-bar .workspace-label {
3645
+ color: var(--text-muted);
3646
+ font-size: 13px;
3647
+ margin-left: 8px;
3648
+ }
3649
+
3650
+ .context-bar .thread-link {
3651
+ margin-left: auto;
3652
+ font-size: 12px;
3653
+ color: var(--blue-link);
3654
+ text-decoration: none;
3655
+ display: flex;
3656
+ align-items: center;
3657
+ gap: 4px;
3658
+ }
3659
+ .context-bar .thread-link:hover { text-decoration: underline; }
3660
+ .context-bar .thread-link svg {
3661
+ width: 12px;
3662
+ height: 12px;
3663
+ fill: none;
3664
+ stroke: currentColor;
3665
+ stroke-width: 2;
3666
+ }
3667
+
3668
+ /* ── Composer card ── */
3669
+ .composer {
3670
+ width: 100%;
3671
+ background: var(--surface);
3672
+ border: 1px solid #818385;
3673
+ border-radius: 8px;
3674
+ overflow: hidden;
3675
+ transition: border-color 0.15s;
3676
+ }
3677
+
3678
+ .composer:focus-within {
3679
+ border-color: var(--border-focus);
3680
+ }
3681
+
3682
+ /* ── Toolbar ── */
3683
+ .toolbar {
3684
+ display: flex;
3685
+ align-items: center;
3686
+ gap: 1px;
3687
+ padding: 4px 8px;
3688
+ border-bottom: 1px solid var(--border);
3689
+ background: var(--surface);
3690
+ }
3691
+
3692
+ .toolbar-btn {
3693
+ display: inline-flex;
3694
+ align-items: center;
3695
+ justify-content: center;
3696
+ width: 32px;
3697
+ height: 32px;
3698
+ border: none;
3699
+ border-radius: 4px;
3700
+ background: transparent;
3701
+ color: var(--text-secondary);
3702
+ cursor: pointer;
3703
+ font-size: 13px;
3704
+ font-family: inherit;
3705
+ transition: all 0.08s;
3706
+ position: relative;
3707
+ flex-shrink: 0;
3708
+ }
3709
+
3710
+ .toolbar-btn:hover {
3711
+ background: var(--surface-raised);
3712
+ color: var(--text);
3713
+ }
3714
+
3715
+ .toolbar-btn.active {
3716
+ background: var(--toolbar-active);
3717
+ color: var(--blue-link);
3718
+ }
3719
+
3720
+ .toolbar-btn.disabled {
3721
+ opacity: 0.3;
3722
+ pointer-events: none;
3723
+ }
3724
+
3725
+ .toolbar-btn svg { width: 16px; height: 16px; fill: currentColor; }
3726
+ .toolbar-btn .b { font-weight: 800; font-size: 15px; line-height: 1; }
3727
+ .toolbar-btn .i { font-style: italic; font-weight: 600; font-size: 15px; font-family: Georgia, serif; line-height: 1; }
3728
+ .toolbar-btn .s { text-decoration: line-through; font-weight: 600; font-size: 13px; line-height: 1; }
3729
+
3730
+ .toolbar-sep {
3731
+ width: 1px;
3732
+ height: 20px;
3733
+ background: var(--border);
3734
+ margin: 0 4px;
3735
+ flex-shrink: 0;
3736
+ }
3737
+
3738
+ /* Tooltip */
3739
+ .toolbar-btn[data-tip]::after {
3740
+ content: attr(data-tip);
3741
+ position: absolute;
3742
+ bottom: calc(100% + 6px);
3743
+ left: 50%;
3744
+ transform: translateX(-50%);
3745
+ background: #1d1d1d;
3746
+ color: #e0e0e0;
3747
+ font-size: 11px;
3748
+ font-weight: 400;
3749
+ font-style: normal;
3750
+ text-decoration: none;
3751
+ padding: 4px 8px;
3752
+ border-radius: 6px;
3753
+ white-space: nowrap;
3754
+ pointer-events: none;
3755
+ opacity: 0;
3756
+ transition: opacity 0.12s;
3757
+ z-index: 20;
3758
+ }
3759
+ .toolbar-btn:hover[data-tip]::after { opacity: 1; }
3760
+
3761
+ /* ── Editor ── */
3762
+ .editor {
3763
+ min-height: 200px;
3764
+ max-height: 55vh;
3765
+ overflow-y: auto;
3766
+ padding: 12px 16px;
3767
+ font-size: 15px;
3768
+ line-height: 1.46668;
3769
+ color: var(--text);
3770
+ outline: none;
3771
+ word-wrap: break-word;
3772
+ overflow-wrap: break-word;
3773
+ }
3774
+
3775
+ .editor:empty::before {
3776
+ content: attr(data-placeholder);
3777
+ color: var(--text-muted);
3778
+ pointer-events: none;
3779
+ }
3780
+
3781
+ .editor b, .editor strong { font-weight: 700; }
3782
+ .editor i, .editor em { font-style: italic; }
3783
+ .editor s, .editor strike, .editor del { text-decoration: line-through; }
3784
+
3785
+ .editor code {
3786
+ background: var(--code-bg);
3787
+ border: 1px solid var(--border);
3788
+ border-radius: 3px;
3789
+ padding: 2px 4px;
3790
+ font-family: Monaco, Menlo, Consolas, 'Courier New', monospace;
3791
+ font-size: 12px;
3792
+ color: #e06c75;
3793
+ }
3794
+
3795
+ .editor pre {
3796
+ background: var(--code-bg);
3797
+ border: 1px solid var(--border);
3798
+ border-radius: 4px;
3799
+ padding: 8px 12px;
3800
+ margin: 4px 0;
3801
+ font-family: Monaco, Menlo, Consolas, 'Courier New', monospace;
3802
+ font-size: 12px;
3803
+ line-height: 1.5;
3804
+ overflow-x: auto;
3805
+ white-space: pre-wrap;
3806
+ color: var(--text);
3807
+ }
3808
+
3809
+ .editor pre code {
3810
+ background: none;
3811
+ border: none;
3812
+ padding: 0;
3813
+ font-size: inherit;
3814
+ color: inherit;
3815
+ }
3816
+
3817
+ /* Adjacent <pre> elements look like one code block (browser splits on Enter) */
3818
+ .editor pre + pre,
3819
+ .editor pre + br + pre {
3820
+ border-top: none;
3821
+ margin-top: -4px; /* collapse gap */
3822
+ padding-top: 0;
3823
+ border-top-left-radius: 0;
3824
+ border-top-right-radius: 0;
3825
+ }
3826
+ .editor pre:has(+ pre),
3827
+ .editor pre:has(+ br + pre) {
3828
+ border-bottom: none;
3829
+ margin-bottom: 0;
3830
+ padding-bottom: 0;
3831
+ border-bottom-left-radius: 0;
3832
+ border-bottom-right-radius: 0;
3833
+ }
3834
+
3835
+ .editor blockquote {
3836
+ border-left: 4px solid var(--blockquote-border);
3837
+ padding: 4px 0 4px 16px;
3838
+ margin: 4px 0;
3839
+ color: var(--text-secondary);
3840
+ }
3841
+
3842
+ .editor ul, .editor ol { padding-left: 26px; margin: 4px 0; }
3843
+ .editor li { margin: 2px 0; }
3844
+ .editor a { color: var(--blue-link); text-decoration: none; }
3845
+ .editor a:hover { text-decoration: underline; }
3846
+
3847
+ /* ── Source textarea ── */
3848
+ .source-editor {
3849
+ display: none;
3850
+ min-height: 200px;
3851
+ max-height: 55vh;
3852
+ width: 100%;
3853
+ padding: 12px 16px;
3854
+ font-family: Monaco, Menlo, Consolas, 'Courier New', monospace;
3855
+ font-size: 13px;
3856
+ line-height: 1.5;
3857
+ background: var(--surface);
3858
+ color: var(--text);
3859
+ border: none;
3860
+ outline: none;
3861
+ resize: vertical;
3862
+ }
3863
+
3864
+ /* ── Bottom bar ── */
3865
+ .bottom-bar {
3866
+ display: flex;
3867
+ align-items: center;
3868
+ justify-content: space-between;
3869
+ padding: 6px 10px;
3870
+ border-top: 1px solid var(--border);
3871
+ background: var(--surface);
3872
+ }
3873
+
3874
+ .bottom-left {
3875
+ display: flex;
3876
+ align-items: center;
3877
+ gap: 10px;
3878
+ }
3879
+
3880
+ .hint {
3881
+ font-size: 12px;
3882
+ color: var(--text-muted);
3883
+ }
3884
+ .hint kbd {
3885
+ background: var(--surface-raised);
3886
+ border: 1px solid var(--border);
3887
+ border-radius: 3px;
3888
+ padding: 1px 4px;
3889
+ font-family: inherit;
3890
+ font-size: 11px;
3891
+ }
3892
+
3893
+ .btn-send {
3894
+ background: var(--green);
3895
+ color: #fff;
3896
+ display: inline-flex;
3897
+ align-items: center;
3898
+ justify-content: center;
3899
+ width: 32px;
3900
+ height: 32px;
3901
+ border: none;
3902
+ border-radius: 4px;
3903
+ cursor: pointer;
3904
+ transition: all 0.1s;
3905
+ flex-shrink: 0;
3906
+ }
3907
+ .btn-send:hover { background: var(--green-hover); }
3908
+ .btn-send:disabled { opacity: 0.3; cursor: not-allowed; }
3909
+ .btn-send svg { width: 16px; height: 16px; fill: currentColor; }
3910
+
3911
+ .cancel-link {
3912
+ background: none;
3913
+ border: none;
3914
+ color: var(--text-muted);
3915
+ font-size: 12px;
3916
+ font-family: inherit;
3917
+ cursor: pointer;
3918
+ padding: 4px 6px;
3919
+ }
3920
+ .cancel-link:hover { color: var(--text-secondary); text-decoration: underline; }
3921
+
3922
+ .btn-aa {
3923
+ display: inline-flex;
3924
+ align-items: center;
3925
+ justify-content: center;
3926
+ width: 32px;
3927
+ height: 32px;
3928
+ border: none;
3929
+ border-radius: 4px;
3930
+ background: transparent;
3931
+ color: var(--text-secondary);
3932
+ cursor: pointer;
3933
+ font-size: 14px;
3934
+ font-weight: 700;
3935
+ font-family: inherit;
3936
+ transition: all 0.08s;
3937
+ flex-shrink: 0;
3938
+ }
3939
+ .btn-aa:hover { background: var(--surface-raised); color: var(--text); }
3940
+ .btn-aa.active { background: var(--toolbar-active); color: var(--blue-link); }
3941
+
3942
+ .btn-source {
3943
+ background: transparent;
3944
+ color: var(--text-muted);
3945
+ font-size: 11px;
3946
+ font-weight: 600;
3947
+ padding: 3px 6px;
3948
+ border-radius: 4px;
3949
+ border: 1px solid transparent;
3950
+ cursor: pointer;
3951
+ font-family: Monaco, Menlo, Consolas, monospace;
3952
+ }
3953
+ .btn-source:hover { color: var(--text-secondary); border-color: var(--border); }
3954
+ .btn-source.active { color: var(--blue-link); border-color: rgba(29,155,209,0.3); }
3955
+
3956
+ /* ── Inline link popover (Slack-style two-field) ── */
3957
+ .link-popover {
3958
+ display: none;
3959
+ position: absolute;
3960
+ z-index: 30;
3961
+ background: var(--surface-raised);
3962
+ border: 1px solid var(--border);
3963
+ border-radius: 8px;
3964
+ padding: 12px 14px;
3965
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5);
3966
+ flex-direction: column;
3967
+ gap: 8px;
3968
+ width: 340px;
3969
+ }
3970
+ .link-popover.visible { display: flex; }
3971
+
3972
+ .link-popover label {
3973
+ font-size: 12px;
3974
+ font-weight: 600;
3975
+ color: var(--text-secondary);
3976
+ display: block;
3977
+ margin-bottom: 3px;
3978
+ }
3979
+
3980
+ .link-popover input {
3981
+ width: 100%;
3982
+ background: var(--bg);
3983
+ border: 1px solid var(--border);
3984
+ border-radius: 4px;
3985
+ padding: 6px 8px;
3986
+ font-size: 13px;
3987
+ color: var(--text);
3988
+ font-family: inherit;
3989
+ outline: none;
3990
+ }
3991
+ .link-popover input:focus { border-color: var(--border-focus); }
3992
+ .link-popover input::placeholder { color: var(--text-muted); }
3993
+
3994
+ .link-popover .popover-actions {
3995
+ display: flex;
3996
+ justify-content: flex-end;
3997
+ gap: 6px;
3998
+ margin-top: 2px;
3999
+ }
4000
+
4001
+ .link-popover .popover-btn {
4002
+ border: none;
4003
+ border-radius: 4px;
4004
+ padding: 5px 12px;
4005
+ font-size: 12px;
4006
+ font-weight: 700;
4007
+ cursor: pointer;
4008
+ font-family: inherit;
4009
+ }
4010
+ .link-popover .popover-btn-save {
4011
+ background: var(--green);
4012
+ color: #fff;
4013
+ }
4014
+ .link-popover .popover-btn-save:hover { background: var(--green-hover); }
4015
+ .link-popover .popover-btn-cancel {
4016
+ background: transparent;
4017
+ color: var(--text-muted);
4018
+ }
4019
+ .link-popover .popover-btn-cancel:hover { color: var(--text-secondary); }
4020
+
4021
+ /* ── Result overlay ── */
4022
+ .overlay {
4023
+ display: none;
4024
+ position: fixed;
4025
+ inset: 0;
4026
+ background: rgba(0,0,0,0.8);
4027
+ z-index: 100;
4028
+ align-items: center;
4029
+ justify-content: center;
4030
+ }
4031
+ .overlay.visible { display: flex; }
4032
+
4033
+ .overlay-msg {
4034
+ text-align: center;
4035
+ font-size: 18px;
4036
+ font-weight: 600;
4037
+ }
4038
+ .overlay-msg.success { color: #2eb67d; }
4039
+ .overlay-msg.error { color: var(--red); }
4040
+ .overlay-msg small {
4041
+ display: block;
4042
+ margin-top: 8px;
4043
+ font-size: 13px;
4044
+ font-weight: 400;
4045
+ color: var(--text-muted);
4046
+ }
4047
+
4048
+ /* Scrollbar */
4049
+ .editor::-webkit-scrollbar { width: 6px; }
4050
+ .editor::-webkit-scrollbar-track { background: transparent; }
4051
+ .editor::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
4052
+ </style>
4053
+ </head>
4054
+ <body>
4055
+
4056
+ <div class="page">
4057
+ <!-- Branding -->
4058
+ <div class="brand">
4059
+ <div class="brand-name">Agent Slack</div>
4060
+ <div class="brand-byline">By <a href="https://stably.ai" target="_blank" rel="noopener"><img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjM3OCIgaGVpZ2h0PSIyMzc4IiB2aWV3Qm94PSIwIDAgMjM3OCAyMzc4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfMTZfNDcpIj4KPHBhdGggZD0iTTExODguOTQgNjE4LjY0NUMxNTAwLjY5IDYxOC42NDUgMTc1My4zNiA4NzEuMzIyIDE3NTMuMzYgMTE4My4wNEMxNzUzLjM2IDE0OTQuNzIgMTUwMC42OSAxNzQ3LjM5IDExODguOTQgMTc0Ny4zOUM4NzcuMjUyIDE3NDcuMzkgNjI0LjU0NyAxNDk0LjcyIDYyNC41NDcgMTE4My4wNEM2MjQuNTQ3IDg3MS4zMTcgODc3LjI1MiA2MTguNjQ1IDExODguOTQgNjE4LjY0NVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xMTg4Ljk0IDBDMTA0MC44MSAwIDg5OS40NiAyOC40NTgxIDc2OC42MTkgNzguMTc0MkM3OTcuMjAyIDEwNy4yMDMgODIwLjg0IDE0MC45OTIgODM4Ljk5IDE3OC4wNTJDOTQ4Ljg2MSAxMzkuODE4IDEwNjYuMzUgMTE4LjA1NSAxMTg5IDExOC4wNTVDMTc3OS41IDExOC4wNTUgMjI1OS44NSA1OTguNTYxIDIyNTkuODUgMTE4OC45NEMyMjU5Ljg1IDE3NzkuMzEgMTc3OS41IDIyNTkuOTEgMTE4OC45NCAyMjU5LjkxQzU5OC41MDEgMjI1OS45MSAxMTguMTIgMTc3OS4zNyAxMTguMTIgMTE4OUMxMTguMTIgOTkwLjEwNiAxNzMuNjA3IDgwNC40MDggMjY4LjQwOSA2NDQuNjNDMjM1LjkxOSA2MTkuNzU5IDIwNy4yNzEgNTg5LjkzNyAxODQuMzI4IDU1NS45NThDNjguMjEzNiA3MzkuNDkzIDAgOTU2LjE4NiAwIDExODlDMCAxODQ0LjYgNTMzLjMzIDIzNzguMDEgMTE4OC45NCAyMzc4LjAxQzE4NDQuNiAyMzc4LjAxIDIzNzggMTg0NC42MSAyMzc4IDExODlDMjM3OCA1MzMuMzk1IDE4NDQuNiAwIDExODguOTQgMFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik00OTguNjI0IDExMS4xMUM2MzMuMjA5IDExMS4xMSA3NDIuMjg3IDIyMC4xODggNzQyLjI4NyAzNTQuNzczQzc0Mi4yODcgNDg5LjM1OCA2MzMuMjA5IDU5OC40MzYgNDk4LjYyNCA1OTguNDM2QzM2NC4wNzEgNTk4LjQzNiAyNTQuOTYxIDQ4OS4zNTggMjU0Ljk2MSAzNTQuNzczQzI1NC45NjEgMjIwLjE4OCAzNjQuMDcxIDExMS4xMSA0OTguNjI0IDExMS4xMVoiIGZpbGw9IndoaXRlIi8+CjwvZz4KPGRlZnM+CjxjbGlwUGF0aCBpZD0iY2xpcDBfMTZfNDciPgo8cmVjdCB3aWR0aD0iMjM3OCIgaGVpZ2h0PSIyMzc4IiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=" alt="" /></a><a href="https://stably.ai" target="_blank" rel="noopener">Stably.ai</a></div>
4061
+ <div class="brand-sub">draft mode</div>
4062
+ </div>
4063
+
4064
+ <!-- Context: channel + thread link -->
4065
+ <div class="context-bar">
4066
+ <svg class="hash-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
4067
+ <line x1="7.5" y1="2" x2="6" y2="18"/><line x1="14" y1="2" x2="12.5" y2="18"/>
4068
+ <line x1="3" y1="7" x2="17.5" y2="7"/><line x1="2.5" y1="13" x2="17" y2="13"/>
4069
+ </svg>
4070
+ <span class="channel" id="channelName"></span>
4071
+ <span class="workspace-label" id="workspaceName"></span>
4072
+ <a class="thread-link" id="threadLink" style="display:none" target="_blank">
4073
+ <span>View thread</span>
4074
+ <svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
4075
+ <path d="M6 3h7v7"/><path d="M13 3L6 10"/>
4076
+ </svg>
4077
+ </a>
4078
+ </div>
4079
+
4080
+ <!-- Composer -->
4081
+ <div class="composer">
4082
+ <div class="toolbar" id="toolbar">
4083
+ <button class="toolbar-btn" data-cmd="bold" data-tip="Bold (&#8984;B)"><span class="b">B</span></button>
4084
+ <button class="toolbar-btn" data-cmd="italic" data-tip="Italic (&#8984;I)"><span class="i">I</span></button>
4085
+ <button class="toolbar-btn" data-cmd="strikethrough" data-tip="Strikethrough (&#8984;&#8679;X)"><span class="s">S</span></button>
4086
+ <div class="toolbar-sep"></div>
4087
+ <button class="toolbar-btn" data-cmd="link" data-tip="Link (&#8984;K)">
4088
+ <svg viewBox="0 0 16 16"><path d="M6.354 5.5H4a3 3 0 0 0 0 6h3a3 3 0 0 0 2.83-4M9.646 10.5H12a3 3 0 0 0 0-6H9a3 3 0 0 0-2.83 4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
4089
+ </button>
4090
+ <div class="toolbar-sep"></div>
4091
+ <button class="toolbar-btn" data-cmd="insertOrderedList" data-tip="Numbered list (&#8984;&#8679;7)">
4092
+ <svg viewBox="0 0 16 16"><text x="1" y="5" font-size="5" fill="currentColor" font-family="system-ui" font-weight="600">1.</text><line x1="7" y1="3.5" x2="15" y2="3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><text x="1" y="10" font-size="5" fill="currentColor" font-family="system-ui" font-weight="600">2.</text><line x1="7" y1="8.5" x2="15" y2="8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><text x="1" y="15" font-size="5" fill="currentColor" font-family="system-ui" font-weight="600">3.</text><line x1="7" y1="13.5" x2="15" y2="13.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
4093
+ </button>
4094
+ <button class="toolbar-btn" data-cmd="insertUnorderedList" data-tip="Bulleted list (&#8984;&#8679;8)">
4095
+ <svg viewBox="0 0 16 16"><circle cx="3" cy="3.5" r="1.5" fill="currentColor"/><line x1="7" y1="3.5" x2="15" y2="3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><circle cx="3" cy="8.5" r="1.5" fill="currentColor"/><line x1="7" y1="8.5" x2="15" y2="8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><circle cx="3" cy="13.5" r="1.5" fill="currentColor"/><line x1="7" y1="13.5" x2="15" y2="13.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
4096
+ </button>
4097
+ <div class="toolbar-sep"></div>
4098
+ <button class="toolbar-btn" data-cmd="blockquote" data-tip="Quote (&#8984;&#8679;9)">
4099
+ <svg viewBox="0 0 16 16"><rect x="1" y="2" width="2.5" height="12" rx="1" fill="currentColor" opacity="0.5"/><line x1="6" y1="4" x2="15" y2="4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="6" y1="8" x2="13" y2="8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="6" y1="12" x2="11" y2="12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
4100
+ </button>
4101
+ <div class="toolbar-sep"></div>
4102
+ <button class="toolbar-btn" data-cmd="code" data-tip="Code (&#8984;E)">
4103
+ <svg viewBox="0 0 16 16"><polyline points="5,3 1,8 5,13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/><polyline points="11,3 15,8 11,13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
4104
+ </button>
4105
+ <button class="toolbar-btn" data-cmd="codeblock" data-tip="Code block (&#8984;&#8679;C)">
4106
+ <svg viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.2"/><polyline points="5,5 3,8 5,11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/><polyline points="11,5 13,8 11,11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
4107
+ </button>
4108
+ </div>
4109
+
4110
+ <div
4111
+ class="editor"
4112
+ id="editor"
4113
+ contenteditable="true"
4114
+ data-placeholder="Message #channel"
4115
+ spellcheck="true"
4116
+ ></div>
4117
+
4118
+ <textarea class="source-editor" id="sourceEditor" spellcheck="false"></textarea>
4119
+
4120
+ <div class="bottom-bar">
4121
+ <div class="bottom-left">
4122
+ <button class="btn-aa active" id="aaToggle" onclick="toggleToolbar()" data-tip="Formatting">Aa</button>
4123
+ <button class="btn-source" id="sourceToggle" onclick="toggleSourceMode()">mrkdwn</button>
4124
+ <span class="hint"><kbd id="modKey">&#8984;</kbd><kbd>Enter</kbd> to send</span>
4125
+ </div>
4126
+ <div style="display:flex;align-items:center;gap:8px;">
4127
+ <button class="cancel-link" onclick="handleCancel()">Cancel</button>
4128
+ <button class="btn-send" id="sendBtn" onclick="handleSend()" disabled>
4129
+ <svg viewBox="0 0 20 20"><path d="M1.7 9.1l7.3 1 .01 0L1.7 9.1zm0 1.8l7.3-1-7.3 1zM1.5 2.1c-.2-.7.5-1.3 1.1-1L19 8.9c.6.3.6 1.2 0 1.5L2.6 18.9c-.7.3-1.3-.3-1.1-1l1.8-6.9L11 10 3.3 8.9 1.5 2.1z"/></svg>
4130
+ </button>
4131
+ </div>
4132
+ </div>
4133
+ </div>
4134
+ </div>
4135
+
4136
+ <!-- Inline link popover (Slack-style two-field) -->
4137
+ <div class="link-popover" id="linkPopover">
4138
+ <div>
4139
+ <label>Text</label>
4140
+ <input type="text" id="linkTextInput" placeholder="Display text">
4141
+ </div>
4142
+ <div>
4143
+ <label>Link</label>
4144
+ <input type="text" id="linkUrlInput" placeholder="https://example.com">
4145
+ </div>
4146
+ <div class="popover-actions">
4147
+ <button class="popover-btn popover-btn-cancel" onclick="closeLinkPopover()">Cancel</button>
4148
+ <button class="popover-btn popover-btn-save" onclick="applyLink()">Save</button>
4149
+ </div>
4150
+ </div>
4151
+
4152
+ <!-- Result overlay -->
4153
+ <div class="overlay" id="resultOverlay">
4154
+ <div class="overlay-msg" id="resultMsg"></div>
4155
+ </div>
4156
+
4157
+ <script>
4158
+ // ─── Config ───
4159
+ const CONFIG = __DRAFT_CONFIG__;
4160
+ const IS_MAC = /Mac|iPhone/.test(navigator.platform);
4161
+ const MOD = IS_MAC ? 'metaKey' : 'ctrlKey';
4162
+
4163
+ // ─── Init header ───
4164
+ document.getElementById('channelName').textContent = CONFIG.channelName;
4165
+ if (CONFIG.workspaceName) {
4166
+ document.getElementById('workspaceName').textContent = CONFIG.workspaceName;
4167
+ }
4168
+ if (!IS_MAC) {
4169
+ document.getElementById('modKey').textContent = 'Ctrl';
4170
+ document.querySelectorAll('[data-tip]').forEach(el => {
4171
+ el.dataset.tip = el.dataset.tip.replace(/\\u2318/g, 'Ctrl+').replace(/\\u21E7/g, 'Shift+');
4172
+ });
4173
+ }
4174
+ if (CONFIG.threadTs) {
4175
+ const link = document.getElementById('threadLink');
4176
+ link.style.display = '';
4177
+ if (CONFIG.threadUrl) {
4178
+ link.href = CONFIG.threadUrl;
4179
+ } else {
4180
+ link.removeAttribute('href');
4181
+ link.style.cursor = 'default';
4182
+ link.style.color = 'var(--text-muted)';
4183
+ }
4184
+ }
4185
+
4186
+ // ─── Editor refs ───
4187
+ const editor = document.getElementById('editor');
4188
+ const sourceEditor = document.getElementById('sourceEditor');
4189
+ const linkPopover = document.getElementById('linkPopover');
4190
+ const linkUrlInput = document.getElementById('linkUrlInput');
4191
+ let sourceMode = false;
4192
+
4193
+ // Set Slack-style placeholder
4194
+ editor.dataset.placeholder = 'Message #' + CONFIG.channelName;
4195
+
4196
+ // ─── Detect if cursor is inside a code element ───
4197
+ function isInsideCode(node) {
4198
+ let el = node;
4199
+ while (el && el !== editor) {
4200
+ if (el.nodeType === 1) {
4201
+ const tag = el.tagName.toLowerCase();
4202
+ if (tag === 'pre' || tag === 'code') { return true; }
4203
+ }
4204
+ el = el.parentNode;
4205
+ }
4206
+ return false;
4207
+ }
4208
+
4209
+ function cursorInCode() {
4210
+ const sel = window.getSelection();
4211
+ if (!sel || sel.rangeCount === 0) { return false; }
4212
+ return isInsideCode(sel.anchorNode);
4213
+ }
4214
+
4215
+ // ─── mrkdwn to HTML (initial content) ───
4216
+ function escapeHtml(t) {
4217
+ return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
4218
+ }
4219
+
4220
+ function mrkdwnToHtml(text) {
4221
+ if (!text) { return ''; }
4222
+ const codeBlocks = [];
4223
+ text = text.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, (_, code) => {
4224
+ codeBlocks.push('<pre>' + escapeHtml(code.trim()) + '</pre>');
4225
+ return '\\x00CB' + (codeBlocks.length - 1) + '\\x00';
4226
+ });
4227
+ const inlineCodes = [];
4228
+ text = text.replace(/\`([^\`\\n]+)\`/g, (_, code) => {
4229
+ inlineCodes.push('<code>' + escapeHtml(code) + '</code>');
4230
+ return '\\x00IC' + (inlineCodes.length - 1) + '\\x00';
4231
+ });
4232
+
4233
+ const lines = text.split('\\n');
4234
+ let html = '', idx = 0;
4235
+ while (idx < lines.length) {
4236
+ const line = lines[idx];
4237
+ if (line.startsWith('> ')) {
4238
+ const q = [];
4239
+ while (idx < lines.length && lines[idx].startsWith('> ')) { q.push(fmtInline(lines[idx].slice(2))); idx++; }
4240
+ html += '<blockquote>' + q.join('<br>') + '</blockquote>'; continue;
4241
+ }
4242
+ if (/^[\\u2022\\-\\*] /.test(line)) {
4243
+ const items = [];
4244
+ while (idx < lines.length && /^[\\u2022\\-\\*] /.test(lines[idx])) { items.push('<li>' + fmtInline(lines[idx].replace(/^[\\u2022\\-\\*] /, '')) + '</li>'); idx++; }
4245
+ html += '<ul>' + items.join('') + '</ul>'; continue;
4246
+ }
4247
+ if (/^\\d+[\\.\\)] /.test(line)) {
4248
+ const items = [];
4249
+ while (idx < lines.length && /^\\d+[\\.\\)] /.test(lines[idx])) { items.push('<li>' + fmtInline(lines[idx].replace(/^\\d+[\\.\\)] /, '')) + '</li>'); idx++; }
4250
+ html += '<ol>' + items.join('') + '</ol>'; continue;
4251
+ }
4252
+ html += (line ? fmtInline(line) : '') + '<br>';
4253
+ idx++;
4254
+ }
4255
+ codeBlocks.forEach((b, i) => { html = html.replace('\\x00CB' + i + '\\x00', b); });
4256
+ inlineCodes.forEach((c, i) => { html = html.replace('\\x00IC' + i + '\\x00', c); });
4257
+ return html.replace(/(<br>)+$/, '');
4258
+ }
4259
+
4260
+ function fmtInline(t) {
4261
+ t = t.replace(/<(https?:\\/\\/[^|>]+)\\|([^>]+)>/g, '<a href="$1" target="_blank">$2</a>');
4262
+ t = t.replace(/<(https?:\\/\\/[^>]+)>/g, '<a href="$1" target="_blank">$1</a>');
4263
+ t = t.replace(/\\*([^\\*]+)\\*/g, '<b>$1</b>');
4264
+ t = t.replace(/(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])/g, '<i>$1</i>');
4265
+ t = t.replace(/~([^~]+)~/g, '<s>$1</s>');
4266
+ return t;
4267
+ }
4268
+
4269
+ // ─── HTML to mrkdwn (submission) ───
4270
+ function htmlToMrkdwn(root) {
4271
+ const visited = new Set();
4272
+ function walk(node) {
4273
+ if (visited.has(node)) { return ''; }
4274
+ if (node.nodeType === 3) { return node.textContent || ''; }
4275
+ if (node.nodeType !== 1) { return ''; }
4276
+ const el = node;
4277
+ const tag = el.tagName.toLowerCase();
4278
+ const kids = () => Array.from(el.childNodes).map(walk).join('');
4279
+ switch (tag) {
4280
+ case 'b': case 'strong': { const c = kids(); return c.trim() ? '*' + c + '*' : c; }
4281
+ case 'i': case 'em': { const c = kids(); return c.trim() ? '_' + c + '_' : c; }
4282
+ case 's': case 'strike': case 'del': { const c = kids(); return c.trim() ? '~' + c + '~' : c; }
4283
+ case 'code': return '\`' + (el.textContent || '') + '\`';
4284
+ case 'pre': {
4285
+ // Collect adjacent pres into one code block
4286
+ const lines = [(el.textContent || '').trimEnd()];
4287
+ let next = el.nextElementSibling;
4288
+ while (next && next.tagName === 'PRE') {
4289
+ lines.push((next.textContent || '').trimEnd());
4290
+ visited.add(next);
4291
+ next = next.nextElementSibling;
4292
+ }
4293
+ return '\`\`\`\\n' + lines.filter(l => l).join('\\n') + '\\n\`\`\`\\n';
4294
+ }
4295
+ case 'blockquote': { const c = kids().trim(); return c.split('\\n').map(l => '> ' + l).join('\\n') + '\\n'; }
4296
+ case 'ul': { let r = ''; for (const li of el.querySelectorAll(':scope > li')) { r += '\\u2022 ' + walk(li).trim() + '\\n'; } return r; }
4297
+ case 'ol': { let r = '', n = 1; for (const li of el.querySelectorAll(':scope > li')) { r += n + '. ' + walk(li).trim() + '\\n'; n++; } return r; }
4298
+ case 'li': return kids();
4299
+ case 'a': { const h = el.getAttribute('href'); const t = kids(); return (h && t && h !== t) ? '<' + h + '|' + t + '>' : (h || t); }
4300
+ case 'br': return '\\n';
4301
+ case 'div': case 'p': {
4302
+ const c = kids();
4303
+ const p = el.parentElement;
4304
+ if (p && ['li','blockquote','td'].includes(p.tagName.toLowerCase())) { return c; }
4305
+ return c.endsWith('\\n') ? c : c + '\\n';
4306
+ }
4307
+ case 'span': {
4308
+ const st = el.style;
4309
+ let c = kids();
4310
+ if (st.fontWeight === 'bold' || Number(st.fontWeight) >= 700) { c = c.trim() ? '*' + c + '*' : c; }
4311
+ if (st.fontStyle === 'italic') { c = c.trim() ? '_' + c + '_' : c; }
4312
+ if (st.textDecoration && st.textDecoration.includes('line-through')) { c = c.trim() ? '~' + c + '~' : c; }
4313
+ return c;
4314
+ }
4315
+ default: return kids();
4316
+ }
4317
+ }
4318
+ return walk(root).replace(/\\n{3,}/g, '\\n\\n').trim();
4319
+ }
4320
+
4321
+ // ─── Init editor content ───
4322
+ if (CONFIG.initialText) { editor.innerHTML = mrkdwnToHtml(CONFIG.initialText); }
4323
+
4324
+ // ─── Toolbar execution ───
4325
+ function execCmd(command) {
4326
+ // Block formatting inside code (except toggling code off)
4327
+ const inCode = cursorInCode();
4328
+ if (inCode && command !== 'code' && command !== 'codeblock') { return; }
4329
+
4330
+ switch (command) {
4331
+ case 'bold':
4332
+ case 'italic':
4333
+ case 'strikethrough':
4334
+ case 'insertOrderedList':
4335
+ case 'insertUnorderedList':
4336
+ document.execCommand(command, false, null);
4337
+ break;
4338
+ case 'blockquote':
4339
+ document.execCommand('formatBlock', false, 'blockquote');
4340
+ break;
4341
+ case 'code': {
4342
+ const sel = window.getSelection();
4343
+ if (!sel || sel.rangeCount === 0) { break; }
4344
+ // If already in code, unwrap
4345
+ if (inCode) {
4346
+ const codeEl = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
4347
+ const code = codeEl.closest('code');
4348
+ if (code) {
4349
+ const text = document.createTextNode(code.textContent || '');
4350
+ code.parentNode.replaceChild(text, code);
4351
+ const r = document.createRange(); r.selectNodeContents(text); sel.removeAllRanges(); sel.addRange(r);
4352
+ }
4353
+ break;
4354
+ }
4355
+ const range = sel.getRangeAt(0);
4356
+ if (range.collapsed) { break; }
4357
+ const code = document.createElement('code');
4358
+ try { range.surroundContents(code); } catch (_) {
4359
+ const frag = range.extractContents(); code.appendChild(frag); range.insertNode(code);
4360
+ }
4361
+ sel.removeAllRanges();
4362
+ const nr = document.createRange(); nr.selectNodeContents(code); sel.addRange(nr);
4363
+ break;
4364
+ }
4365
+ case 'codeblock': {
4366
+ if (inCode) { break; } // No nesting
4367
+ const sel = window.getSelection();
4368
+ if (!sel) { break; }
4369
+ const pre = document.createElement('pre');
4370
+ if (!sel.isCollapsed) {
4371
+ const range = sel.getRangeAt(0);
4372
+ pre.textContent = range.extractContents().textContent || '';
4373
+ range.insertNode(pre);
4374
+ } else {
4375
+ pre.innerHTML = '<br>';
4376
+ const range = sel.getRangeAt(0);
4377
+ range.insertNode(pre);
4378
+ const nr = document.createRange(); nr.setStart(pre, 0); nr.collapse(true);
4379
+ sel.removeAllRanges(); sel.addRange(nr);
4380
+ }
4381
+ break;
4382
+ }
4383
+ case 'link':
4384
+ openLinkPopover();
4385
+ return; // Don't refocus — popover takes focus
4386
+ }
4387
+ editor.focus();
4388
+ updateToolbarState();
4389
+ }
4390
+
4391
+ // Toolbar button clicks
4392
+ document.querySelectorAll('.toolbar-btn[data-cmd]').forEach(btn => {
4393
+ btn.addEventListener('mousedown', (e) => {
4394
+ e.preventDefault();
4395
+ execCmd(btn.dataset.cmd);
4396
+ });
4397
+ });
4398
+
4399
+ // ─── Toolbar state ───
4400
+ function updateToolbarState() {
4401
+ const inCode = cursorInCode();
4402
+ document.querySelectorAll('.toolbar-btn[data-cmd]').forEach(btn => {
4403
+ const cmd = btn.dataset.cmd;
4404
+ // Disable non-code buttons when inside code
4405
+ if (inCode && cmd !== 'code' && cmd !== 'codeblock') {
4406
+ btn.classList.add('disabled');
4407
+ btn.classList.remove('active');
4408
+ return;
4409
+ }
4410
+ btn.classList.remove('disabled');
4411
+ let active = false;
4412
+ try {
4413
+ if (['bold','italic','strikethrough','insertOrderedList','insertUnorderedList'].includes(cmd)) {
4414
+ active = document.queryCommandState(cmd);
4415
+ }
4416
+ if (cmd === 'code') { active = inCode; }
4417
+ } catch (_) {}
4418
+ btn.classList.toggle('active', active);
4419
+ });
4420
+ }
4421
+
4422
+ editor.addEventListener('keyup', updateToolbarState);
4423
+ editor.addEventListener('mouseup', updateToolbarState);
4424
+ editor.addEventListener('focus', updateToolbarState);
4425
+
4426
+ // ─── Keyboard shortcuts (Slack-identical) ───
4427
+ editor.addEventListener('keydown', (e) => {
4428
+ const mod = e[MOD];
4429
+
4430
+ // Cmd+Enter → Send
4431
+ if (mod && e.key === 'Enter') { e.preventDefault(); handleSend(); return; }
4432
+
4433
+ // Cmd+B → Bold
4434
+ if (mod && !e.shiftKey && e.key === 'b') { e.preventDefault(); execCmd('bold'); return; }
4435
+
4436
+ // Cmd+I → Italic
4437
+ if (mod && !e.shiftKey && e.key === 'i') { e.preventDefault(); execCmd('italic'); return; }
4438
+
4439
+ // Cmd+Shift+X → Strikethrough
4440
+ if (mod && e.shiftKey && (e.key === 'X' || e.key === 'x')) { e.preventDefault(); execCmd('strikethrough'); return; }
4441
+
4442
+ // Cmd+E → Inline code (Slack's actual shortcut)
4443
+ if (mod && !e.shiftKey && e.key === 'e') { e.preventDefault(); execCmd('code'); return; }
4444
+
4445
+ // Cmd+Shift+C → Code block
4446
+ if (mod && e.shiftKey && (e.key === 'C' || e.key === 'c' || e.code === 'KeyC')) { e.preventDefault(); execCmd('codeblock'); return; }
4447
+
4448
+ // Cmd+K → Link
4449
+ if (mod && !e.shiftKey && e.key === 'k') { e.preventDefault(); execCmd('link'); return; }
4450
+
4451
+ // Cmd+Shift+7 → Numbered list
4452
+ if (mod && e.shiftKey && e.key === '7') { e.preventDefault(); execCmd('insertOrderedList'); return; }
4453
+
4454
+ // Cmd+Shift+8 → Bulleted list
4455
+ if (mod && e.shiftKey && e.key === '8') { e.preventDefault(); execCmd('insertUnorderedList'); return; }
4456
+
4457
+ // Cmd+Shift+9 → Blockquote
4458
+ if (mod && e.shiftKey && e.key === '9') { e.preventDefault(); execCmd('blockquote'); return; }
4459
+ });
4460
+
4461
+ // ─── Code block: merge split <pre> elements + escape logic ───
4462
+ function findParentPre(node) {
4463
+ while (node && node !== editor) {
4464
+ if (node.nodeType === 1 && node.tagName === 'PRE') { return node; }
4465
+ node = node.parentNode;
4466
+ }
4467
+ return null;
4468
+ }
4469
+
4470
+ function exitCodeBlock(pre) {
4471
+ const sel = window.getSelection();
4472
+ const p = document.createElement('div');
4473
+ p.innerHTML = '<br>';
4474
+ if (pre.parentNode === editor) {
4475
+ pre.parentNode.insertBefore(p, pre.nextSibling);
4476
+ } else {
4477
+ const wrapper = pre.parentNode;
4478
+ wrapper.parentNode.insertBefore(p, wrapper.nextSibling);
4479
+ }
4480
+ const nr = document.createRange();
4481
+ nr.setStart(p, 0);
4482
+ nr.collapse(true);
4483
+ sel.removeAllRanges();
4484
+ sel.addRange(nr);
4485
+ }
4486
+
4487
+ // Code block management: the browser splits <pre> on Enter, creating adjacent
4488
+ // <pre> elements. We let this happen and handle it via:
4489
+ // 1. CSS: adjacent pres look like one block (no gaps/borders between them)
4490
+ // 2. Serialization: htmlToMrkdwn treats adjacent pres as one code block
4491
+ // 3. Escape: when user creates an empty pre after another empty pre, exit code
4492
+
4493
+ // Track code block escape (double-Enter on empty line)
4494
+ editor.addEventListener('input', () => {
4495
+ const pres = Array.from(editor.querySelectorAll('pre'));
4496
+ if (pres.length < 2) { return; }
4497
+
4498
+ // Check for escape: last two adjacent pres are both empty
4499
+ for (let i = pres.length - 1; i > 0; i--) {
4500
+ const cur = pres[i];
4501
+ const prev = pres[i - 1];
4502
+ const curEmpty = !cur.textContent?.trim();
4503
+ const prevEmpty = !prev.textContent?.trim();
4504
+
4505
+ if (curEmpty && prevEmpty) {
4506
+ // Double-Enter escape: remove both empty pres, place cursor after
4507
+ const lastRealPre = pres[i - 2] || null;
4508
+ // Remove the two empty pres and their wrappers
4509
+ [cur, prev].forEach(p => {
4510
+ const parent = p.parentNode;
4511
+ p.remove();
4512
+ if (parent && parent !== editor && parent.tagName === 'DIV' && !parent.textContent?.trim()) {
4513
+ parent.remove();
4514
+ }
4515
+ });
4516
+ if (lastRealPre) {
4517
+ exitCodeBlock(lastRealPre);
4518
+ } else {
4519
+ // No code left — just place cursor
4520
+ const sel = window.getSelection();
4521
+ if (sel) {
4522
+ const div = document.createElement('div');
4523
+ div.innerHTML = '<br>';
4524
+ editor.appendChild(div);
4525
+ const r = document.createRange();
4526
+ r.setStart(div, 0);
4527
+ r.collapse(true);
4528
+ sel.removeAllRanges();
4529
+ sel.addRange(r);
4530
+ }
4531
+ }
4532
+ return;
4533
+ }
4534
+ }
4535
+ });
4536
+
4537
+ // ArrowDown at end of last code block → exit
4538
+ editor.addEventListener('keydown', (e) => {
4539
+ if (e.key === 'ArrowDown') {
4540
+ const sel = window.getSelection();
4541
+ if (!sel || sel.rangeCount === 0) { return; }
4542
+ const pre = findParentPre(sel.anchorNode);
4543
+ if (pre && !pre.nextElementSibling) {
4544
+ e.preventDefault();
4545
+ exitCodeBlock(pre);
4546
+ }
4547
+ }
4548
+ }, true);
4549
+
4550
+ // ─── Paste handler ───
4551
+ editor.addEventListener('paste', (e) => {
4552
+ const cd = e.clipboardData;
4553
+ if (!cd) { return; }
4554
+ const html = cd.getData('text/html');
4555
+ if (html) {
4556
+ e.preventDefault();
4557
+ const tmp = document.createElement('div');
4558
+ tmp.innerHTML = html;
4559
+ tmp.querySelectorAll('script,style,meta,link').forEach(el => el.remove());
4560
+ tmp.querySelectorAll('[style]').forEach(el => {
4561
+ const s = el.style;
4562
+ const keep = {};
4563
+ if (s.fontWeight === 'bold' || Number(s.fontWeight) >= 700) { keep.fontWeight = s.fontWeight; }
4564
+ if (s.fontStyle === 'italic') { keep.fontStyle = s.fontStyle; }
4565
+ if (s.textDecoration?.includes('line-through')) { keep.textDecoration = 'line-through'; }
4566
+ el.removeAttribute('style');
4567
+ Object.assign(el.style, keep);
4568
+ });
4569
+ document.execCommand('insertHTML', false, tmp.innerHTML);
4570
+ }
4571
+ });
4572
+
4573
+ // ─── Link popover (Slack-style: select text, Cmd+K, two-field form) ───
4574
+ let linkSavedRange = null;
4575
+ let linkPopoverOpen = false;
4576
+ const linkTextInput = document.getElementById('linkTextInput');
4577
+
4578
+ function openLinkPopover() {
4579
+ const sel = window.getSelection();
4580
+ if (!sel || sel.rangeCount === 0) { return; }
4581
+
4582
+ linkSavedRange = sel.getRangeAt(0).cloneRange();
4583
+ const selectedText = sel.toString();
4584
+
4585
+ // Position near selection
4586
+ const rect = linkSavedRange.getBoundingClientRect();
4587
+ const editorRect = editor.getBoundingClientRect();
4588
+ linkPopover.style.top = (rect.bottom + window.scrollY + 6) + 'px';
4589
+ linkPopover.style.left = Math.max(8, Math.min(rect.left + window.scrollX, editorRect.right - 360)) + 'px';
4590
+
4591
+ // Pre-fill text field with selected text
4592
+ linkTextInput.value = selectedText;
4593
+
4594
+ // If selection is already a link, pre-fill URL
4595
+ let existingUrl = '';
4596
+ const anchor = sel.anchorNode?.parentElement?.closest('a');
4597
+ if (anchor) {
4598
+ existingUrl = anchor.getAttribute('href') || '';
4599
+ linkTextInput.value = anchor.textContent || selectedText;
4600
+ }
4601
+ linkUrlInput.value = existingUrl;
4602
+
4603
+ // Show popover (use rAF to avoid same-frame mousedown closing it)
4604
+ requestAnimationFrame(() => {
4605
+ linkPopover.classList.add('visible');
4606
+ linkPopoverOpen = true;
4607
+ linkUrlInput.focus();
4608
+ linkUrlInput.select();
4609
+ });
4610
+ }
4611
+
4612
+ function closeLinkPopover() {
4613
+ linkPopover.classList.remove('visible');
4614
+ linkPopoverOpen = false;
4615
+ editor.focus();
4616
+ if (linkSavedRange) {
4617
+ const sel = window.getSelection();
4618
+ sel.removeAllRanges();
4619
+ sel.addRange(linkSavedRange);
4620
+ }
4621
+ }
4622
+
4623
+ function applyLink() {
4624
+ const url = linkUrlInput.value.trim();
4625
+ const text = linkTextInput.value.trim();
4626
+ linkPopover.classList.remove('visible');
4627
+ linkPopoverOpen = false;
4628
+
4629
+ if (!url || !linkSavedRange) { editor.focus(); return; }
4630
+
4631
+ const sel = window.getSelection();
4632
+ sel.removeAllRanges();
4633
+ sel.addRange(linkSavedRange);
4634
+
4635
+ // Check if we're updating an existing link
4636
+ const anchor = sel.anchorNode?.parentElement?.closest('a');
4637
+ if (anchor) {
4638
+ anchor.setAttribute('href', url);
4639
+ if (text) { anchor.textContent = text; }
4640
+ } else {
4641
+ // Create new link
4642
+ const a = document.createElement('a');
4643
+ a.href = url;
4644
+ a.target = '_blank';
4645
+ a.textContent = text || url;
4646
+
4647
+ if (!linkSavedRange.collapsed) {
4648
+ linkSavedRange.deleteContents();
4649
+ }
4650
+ linkSavedRange.insertNode(a);
4651
+
4652
+ const nr = document.createRange();
4653
+ nr.setStartAfter(a);
4654
+ nr.collapse(true);
4655
+ sel.removeAllRanges();
4656
+ sel.addRange(nr);
4657
+ }
4658
+
4659
+ editor.focus();
4660
+ linkSavedRange = null;
4661
+ }
4662
+
4663
+ // Popover key handling — both inputs
4664
+ [linkTextInput, linkUrlInput].forEach(input => {
4665
+ input.addEventListener('keydown', (e) => {
4666
+ if (e.key === 'Enter') { e.preventDefault(); applyLink(); }
4667
+ if (e.key === 'Escape') { e.preventDefault(); closeLinkPopover(); }
4668
+ });
4669
+ });
4670
+
4671
+ // Close popover on outside click (delayed check to avoid same-event close)
4672
+ document.addEventListener('mousedown', (e) => {
4673
+ if (linkPopoverOpen && !linkPopover.contains(e.target) && !e.target.closest('.toolbar-btn')) {
4674
+ closeLinkPopover();
4675
+ }
4676
+ });
4677
+
4678
+ // ─── Toolbar toggle (Aa button) ───
4679
+ let toolbarVisible = true;
4680
+ function toggleToolbar() {
4681
+ toolbarVisible = !toolbarVisible;
4682
+ document.getElementById('toolbar').style.display = toolbarVisible ? '' : 'none';
4683
+ document.getElementById('aaToggle').classList.toggle('active', toolbarVisible);
4684
+ }
4685
+
4686
+ // ─── Send button state ───
4687
+ function updateSendBtn() {
4688
+ const text = sourceMode ? sourceEditor.value.trim() : (editor.textContent || '').trim();
4689
+ document.getElementById('sendBtn').disabled = !text;
4690
+ }
4691
+ editor.addEventListener('input', updateSendBtn);
4692
+
4693
+ // ─── Source mode ───
4694
+ function toggleSourceMode() {
4695
+ sourceMode = !sourceMode;
4696
+ document.getElementById('sourceToggle').classList.toggle('active', sourceMode);
4697
+ if (sourceMode) {
4698
+ sourceEditor.value = htmlToMrkdwn(editor);
4699
+ editor.style.display = 'none';
4700
+ sourceEditor.style.display = 'block';
4701
+ document.getElementById('toolbar').style.display = 'none';
4702
+ document.getElementById('aaToggle').style.display = 'none';
4703
+ sourceEditor.focus();
4704
+ } else {
4705
+ editor.innerHTML = mrkdwnToHtml(sourceEditor.value);
4706
+ sourceEditor.style.display = 'none';
4707
+ editor.style.display = '';
4708
+ document.getElementById('toolbar').style.display = toolbarVisible ? '' : 'none';
4709
+ document.getElementById('aaToggle').style.display = '';
4710
+ editor.focus();
4711
+ }
4712
+ updateSendBtn();
4713
+ }
4714
+
4715
+ sourceEditor.addEventListener('keydown', (e) => {
4716
+ if (e[MOD] && e.key === 'Enter') { e.preventDefault(); handleSend(); }
4717
+ });
4718
+ sourceEditor.addEventListener('input', updateSendBtn);
4719
+
4720
+ // ─── Send / Cancel ───
4721
+ let sending = false;
4722
+
4723
+ async function handleSend() {
4724
+ if (sending) { return; }
4725
+ const text = sourceMode ? sourceEditor.value.trim() : htmlToMrkdwn(editor);
4726
+ if (!text) { editor.focus(); return; }
4727
+
4728
+ sending = true;
4729
+ const btn = document.getElementById('sendBtn');
4730
+ btn.disabled = true;
4731
+ btn.innerHTML = '\\u2026';
4732
+
4733
+ try {
4734
+ const resp = await fetch('/send', {
4735
+ method: 'POST',
4736
+ headers: { 'Content-Type': 'application/json' },
4737
+ body: JSON.stringify({ text }),
4738
+ });
4739
+ const data = await resp.json();
4740
+ if (data.ok) {
4741
+ let sub = 'You can close this tab.';
4742
+ if (data.ts && CONFIG.workspaceUrl && CONFIG.channelId) {
4743
+ const tsNoDot = data.ts.replace('.', '');
4744
+ const baseUrl = CONFIG.workspaceUrl.replace(/\\/$/, '');
4745
+ const msgUrl = baseUrl + '/archives/' + CONFIG.channelId + '/p' + tsNoDot;
4746
+ sub = '<a href="' + msgUrl + '" target="_blank" style="color:var(--accent);text-decoration:none;">View in Slack \\u2197</a><br><span style="opacity:0.6;font-size:12px;">You can close this tab.</span>';
4747
+ }
4748
+ showOverlay('Message sent \\u2705', sub, 'success');
4749
+ } else {
4750
+ throw new Error(data.error || 'Send failed');
4751
+ }
4752
+ } catch (err) {
4753
+ showOverlay('Failed to send', err.message, 'error');
4754
+ sending = false;
4755
+ btn.disabled = false;
4756
+ btn.innerHTML = '<svg viewBox="0 0 20 20" style="width:16px;height:16px;fill:currentColor"><path d="M1.7 9.1l7.3 1 .01 0L1.7 9.1zm0 1.8l7.3-1-7.3 1zM1.5 2.1c-.2-.7.5-1.3 1.1-1L19 8.9c.6.3.6 1.2 0 1.5L2.6 18.9c-.7.3-1.3-.3-1.1-1l1.8-6.9L11 10 3.3 8.9 1.5 2.1z"/></svg>';
4757
+ }
4758
+ }
4759
+
4760
+ async function handleCancel() {
4761
+ try { await fetch('/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); } catch (_) {}
4762
+ showOverlay('Draft cancelled', 'You can close this tab.', 'success');
4763
+ }
4764
+
4765
+ function showOverlay(title, sub, type) {
4766
+ const overlay = document.getElementById('resultOverlay');
4767
+ const msg = document.getElementById('resultMsg');
4768
+ msg.className = 'overlay-msg ' + type;
4769
+ msg.innerHTML = title + '<small>' + sub + '</small>';
4770
+ overlay.classList.add('visible');
4771
+ }
4772
+
4773
+ // ─── Focus ───
4774
+ editor.focus();
4775
+ updateSendBtn();
4776
+ </script>
4777
+ </body>
4778
+ </html>`;
4779
+
4780
+ // src/cli/draft-actions.ts
4781
+ async function draftMessage(input) {
4782
+ const target = parseMsgTarget(String(input.targetInput));
4783
+ if (target.kind === "url") {
4784
+ const { ref } = target;
4785
+ warnOnTruncatedSlackUrl(ref);
4786
+ return input.ctx.withAutoRefresh({
4787
+ workspaceUrl: ref.workspace_url,
4788
+ work: async () => {
4789
+ const { client } = await input.ctx.getClientForWorkspace(ref.workspace_url);
4790
+ const msg = await fetchMessage(client, { ref });
4791
+ const threadTs = input.options.threadTs ?? msg.thread_ts ?? msg.ts;
4792
+ const channelName = await resolveChannelName(client, ref.channel_id);
4793
+ return draftWithEditor({
4794
+ channelName,
4795
+ channelId: ref.channel_id,
4796
+ workspaceUrl: ref.workspace_url,
4797
+ threadTs,
4798
+ initialText: input.initialText,
4799
+ sendFn: async (text) => {
4800
+ const resp = await client.api("chat.postMessage", {
4801
+ channel: ref.channel_id,
4802
+ text,
4803
+ thread_ts: threadTs
4804
+ });
4805
+ return { ts: resp.ts };
4806
+ }
4807
+ });
4808
+ }
4809
+ });
4810
+ }
4811
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
4812
+ await input.ctx.assertWorkspaceSpecifiedForChannelNames({
4813
+ workspaceUrl,
4814
+ channels: [String(target.channel)]
4815
+ });
4816
+ return input.ctx.withAutoRefresh({
4817
+ workspaceUrl,
4818
+ work: async () => {
4819
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
4820
+ const channelId = await resolveChannelId(client, String(target.channel));
4821
+ const normalized = normalizeChannelInput(target.channel);
4822
+ const channelName = normalized.kind === "name" ? normalized.value : await resolveChannelName(client, channelId);
4823
+ return draftWithEditor({
4824
+ channelName,
4825
+ channelId,
4826
+ workspaceUrl,
4827
+ threadTs: input.options.threadTs,
4828
+ initialText: input.initialText,
4829
+ sendFn: async (text) => {
4830
+ const resp = await client.api("chat.postMessage", {
4831
+ channel: channelId,
4832
+ text,
4833
+ thread_ts: input.options.threadTs
4834
+ });
4835
+ return { ts: resp.ts };
4836
+ }
4837
+ });
4838
+ }
4839
+ });
4840
+ }
4841
+ async function draftWithEditor(input) {
4842
+ if (process.env.CI) {
4843
+ if (!input.initialText) {
4844
+ throw new Error("In CI mode, initial text is required (no editor available)");
4845
+ }
4846
+ const result2 = await input.sendFn(input.initialText);
4847
+ return { ok: true, sent: true, editor: "skipped", ts: result2.ts };
4848
+ }
4849
+ const result = await openDraftEditor({
4850
+ channelName: input.channelName,
4851
+ channelId: input.channelId,
4852
+ workspaceUrl: input.workspaceUrl,
4853
+ threadTs: input.threadTs,
4854
+ initialText: input.initialText,
4855
+ onSend: input.sendFn
4856
+ });
4857
+ if ("cancelled" in result) {
4858
+ return { ok: true, cancelled: true };
4859
+ }
4860
+ return { ok: true, sent: true };
4861
+ }
4862
+
3372
4863
  // src/cli/message-command.ts
3373
4864
  function collectOptionValue(value, previous = []) {
3374
4865
  return [...previous, value];
@@ -3439,6 +4930,21 @@ function registerMessageCommand(input) {
3439
4930
  process.exitCode = 1;
3440
4931
  }
3441
4932
  });
4933
+ messageCmd.command("draft").description("Open a rich Slack-like editor to compose and send a message").argument("<target>", "Slack message URL, #name/name, or channel id").argument("[text]", "Initial draft text (mrkdwn format)").option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)").option("--thread-ts <ts>", "Thread root ts to post into (optional)").action(async (...args) => {
4934
+ const [targetInput, text, options] = args;
4935
+ try {
4936
+ const payload = await draftMessage({
4937
+ ctx: input.ctx,
4938
+ targetInput,
4939
+ initialText: text,
4940
+ options
4941
+ });
4942
+ console.log(JSON.stringify(payload, null, 2));
4943
+ } catch (err) {
4944
+ console.error(input.ctx.errorMessage(err));
4945
+ process.exitCode = 1;
4946
+ }
4947
+ });
3442
4948
  const reactCmd = messageCmd.command("react").description("Add or remove reactions");
3443
4949
  reactCmd.command("add").description("Add a reaction to a message").argument("<target>", "Slack message URL, #channel, or channel ID").argument("<emoji>", "Emoji to react with (:rocket:, rocket, or \uD83D\uDE80)").option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)").option("--ts <ts>", "Message ts (required when using #channel/channel id)").action(async (...args) => {
3444
4950
  const [targetInput, emoji2, options] = args;
@@ -4184,10 +5690,11 @@ function registerSearchCommand(input) {
4184
5690
  }
4185
5691
 
4186
5692
  // src/lib/update.ts
5693
+ import { execSync as execSync3 } from "node:child_process";
4187
5694
  import { createHash } from "node:crypto";
4188
5695
  import { chmod, copyFile, mkdir as mkdir5, readFile as readFile5, rename, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
4189
5696
  import { tmpdir as tmpdir2 } from "node:os";
4190
- import { join as join10 } from "node:path";
5697
+ import { basename as basename2, join as join10 } from "node:path";
4191
5698
  var REPO = "stablyai/agent-slack";
4192
5699
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
4193
5700
  function getCachePath() {
@@ -4247,6 +5754,37 @@ async function checkForUpdate(force = false) {
4247
5754
  update_available: compareSemver(latest, current) > 0
4248
5755
  };
4249
5756
  }
5757
+ function detectInstallMethod() {
5758
+ if (process.versions.bun && basename2(process.execPath) === "bun") {
5759
+ return "bun";
5760
+ }
5761
+ const execName = basename2(process.execPath);
5762
+ if (["node", "nodejs", "node.exe"].includes(execName) || process.env.npm_execpath) {
5763
+ return "npm";
5764
+ }
5765
+ return "binary";
5766
+ }
5767
+ function getUpdateCommand(method) {
5768
+ const m = method ?? detectInstallMethod();
5769
+ switch (m) {
5770
+ case "npm":
5771
+ return "npm install -g agent-slack@latest";
5772
+ case "bun":
5773
+ return "bun install -g agent-slack@latest";
5774
+ case "binary":
5775
+ return "agent-slack update";
5776
+ }
5777
+ }
5778
+ function performPackageManagerUpdate(method) {
5779
+ const cmd = getUpdateCommand(method);
5780
+ try {
5781
+ execSync3(cmd, { stdio: ["inherit", "pipe", "inherit"] });
5782
+ return { success: true, message: `Updated agent-slack via: ${cmd}` };
5783
+ } catch (err) {
5784
+ const msg = err instanceof Error ? err.message : String(err);
5785
+ return { success: false, message: `Failed to run "${cmd}": ${msg}` };
5786
+ }
5787
+ }
4250
5788
  function detectPlatformAsset() {
4251
5789
  const platform6 = process.platform === "win32" ? "windows" : process.platform;
4252
5790
  const archMap = { x64: "x64", arm64: "arm64" };
@@ -4314,8 +5852,9 @@ async function backgroundUpdateCheck() {
4314
5852
  try {
4315
5853
  const result = await checkForUpdate();
4316
5854
  if (result?.update_available) {
5855
+ const cmd = getUpdateCommand();
4317
5856
  process.stderr.write(`
4318
- Update available: ${result.current} → ${result.latest}. Run "agent-slack update" to upgrade.
5857
+ Update available: ${result.current} → ${result.latest}. Run "${cmd}" to upgrade.
4319
5858
  `);
4320
5859
  }
4321
5860
  } catch {}
@@ -4325,6 +5864,7 @@ Update available: ${result.current} → ${result.latest}. Run "agent-slack updat
4325
5864
  function registerUpdateCommand(input) {
4326
5865
  input.program.command("update").description("Update agent-slack to the latest version").option("--check", "Only check for updates (don't install)").action(async (...args) => {
4327
5866
  const [options] = args;
5867
+ const method = detectInstallMethod();
4328
5868
  try {
4329
5869
  const result = await checkForUpdate(true);
4330
5870
  if (!result) {
@@ -4333,16 +5873,28 @@ function registerUpdateCommand(input) {
4333
5873
  return;
4334
5874
  }
4335
5875
  if (!result.update_available) {
4336
- console.log(JSON.stringify(pruneEmpty({ ...result, status: "up_to_date" }), null, 2));
5876
+ console.log(JSON.stringify(pruneEmpty({ ...result, install_method: method, status: "up_to_date" }), null, 2));
4337
5877
  return;
4338
5878
  }
4339
5879
  if (options.check) {
4340
- console.log(JSON.stringify(pruneEmpty({ ...result, status: "update_available" }), null, 2));
5880
+ console.log(JSON.stringify(pruneEmpty({
5881
+ ...result,
5882
+ install_method: method,
5883
+ update_command: getUpdateCommand(method),
5884
+ status: "update_available"
5885
+ }), null, 2));
4341
5886
  return;
4342
5887
  }
4343
5888
  process.stderr.write(`Updating agent-slack ${result.current} → ${result.latest}...
4344
5889
  `);
4345
- const outcome = await performUpdate(result.latest);
5890
+ let outcome;
5891
+ if (method === "npm" || method === "bun") {
5892
+ process.stderr.write(`Detected install method: ${method}
5893
+ `);
5894
+ outcome = performPackageManagerUpdate(method);
5895
+ } else {
5896
+ outcome = await performUpdate(result.latest);
5897
+ }
4346
5898
  if (!outcome.success) {
4347
5899
  console.error(outcome.message);
4348
5900
  process.exitCode = 1;
@@ -4350,6 +5902,7 @@ function registerUpdateCommand(input) {
4350
5902
  }
4351
5903
  console.log(JSON.stringify(pruneEmpty({
4352
5904
  status: "updated",
5905
+ install_method: method,
4353
5906
  previous_version: result.current,
4354
5907
  new_version: result.latest,
4355
5908
  message: outcome.message
@@ -4417,6 +5970,17 @@ async function resolveUserId2(client, input) {
4417
5970
  if (/^U[A-Z0-9]{8,}$/.test(trimmed)) {
4418
5971
  return trimmed;
4419
5972
  }
5973
+ const looksLikeEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(trimmed) && !trimmed.startsWith("@");
5974
+ if (looksLikeEmail) {
5975
+ try {
5976
+ const byEmail = await client.api("users.lookupByEmail", { email: trimmed });
5977
+ const user = isRecord5(byEmail.user) ? byEmail.user : null;
5978
+ const userId = user ? getString(user.id) : undefined;
5979
+ if (userId) {
5980
+ return userId;
5981
+ }
5982
+ } catch {}
5983
+ }
4420
5984
  const handle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
4421
5985
  if (!handle) {
4422
5986
  return null;
@@ -4425,7 +5989,17 @@ async function resolveUserId2(client, input) {
4425
5989
  for (;; ) {
4426
5990
  const resp = await client.api("users.list", { limit: 200, cursor });
4427
5991
  const members = asArray(resp.members).filter(isRecord5);
4428
- const found = members.find((m) => getString(m.name) === handle);
5992
+ const found = members.find((m) => {
5993
+ if (getString(m.name) === handle) {
5994
+ return true;
5995
+ }
5996
+ if (looksLikeEmail) {
5997
+ const profile = isRecord5(m.profile) ? m.profile : null;
5998
+ const email = profile ? getString(profile.email) : undefined;
5999
+ return Boolean(email) && email?.toLowerCase() === trimmed.toLowerCase();
6000
+ }
6001
+ return false;
6002
+ });
4429
6003
  if (found) {
4430
6004
  const id = getString(found.id);
4431
6005
  if (id) {
@@ -4500,6 +6074,195 @@ function registerUserCommand(input) {
4500
6074
  });
4501
6075
  }
4502
6076
 
6077
+ // src/slack/channel-admin.ts
6078
+ async function createChannel(client, input) {
6079
+ const name = input.name.trim();
6080
+ if (!name) {
6081
+ throw new Error("Channel name is empty");
6082
+ }
6083
+ const resp = await client.api("conversations.create", {
6084
+ name,
6085
+ is_private: Boolean(input.isPrivate)
6086
+ });
6087
+ const channel = isRecord5(resp.channel) ? resp.channel : null;
6088
+ const id = channel ? getString(channel.id) : undefined;
6089
+ const channelName = channel ? getString(channel.name) : undefined;
6090
+ const isPrivate = channel && typeof channel.is_private === "boolean" ? channel.is_private : Boolean(input.isPrivate);
6091
+ if (!id || !channelName) {
6092
+ throw new Error("conversations.create returned no channel");
6093
+ }
6094
+ return {
6095
+ channel: {
6096
+ id,
6097
+ name: channelName,
6098
+ is_private: isPrivate
6099
+ }
6100
+ };
6101
+ }
6102
+ async function inviteUsersToChannel(client, input) {
6103
+ const invitedUserIds = [];
6104
+ const alreadyInChannelUserIds = [];
6105
+ for (const userId of input.userIds) {
6106
+ try {
6107
+ await client.api("conversations.invite", {
6108
+ channel: input.channelId,
6109
+ users: userId
6110
+ });
6111
+ invitedUserIds.push(userId);
6112
+ } catch (err) {
6113
+ const message = err instanceof Error ? err.message : String(err);
6114
+ if (message.includes("already_in_channel")) {
6115
+ alreadyInChannelUserIds.push(userId);
6116
+ continue;
6117
+ }
6118
+ throw err;
6119
+ }
6120
+ }
6121
+ return {
6122
+ invited_user_ids: invitedUserIds,
6123
+ already_in_channel_user_ids: alreadyInChannelUserIds
6124
+ };
6125
+ }
6126
+ function parseInviteUsersCsv(input) {
6127
+ return Array.from(new Set(asArray(input.split(",")).map((value) => String(value).trim()).filter(Boolean)));
6128
+ }
6129
+ function splitEmailsFromInviteTargets(input) {
6130
+ const emails = [];
6131
+ const nonEmailTargets = [];
6132
+ for (const target of input) {
6133
+ if (isLikelyEmail(target)) {
6134
+ emails.push(target);
6135
+ continue;
6136
+ }
6137
+ nonEmailTargets.push(target);
6138
+ }
6139
+ return {
6140
+ emails: Array.from(new Set(emails)),
6141
+ non_email_targets: nonEmailTargets
6142
+ };
6143
+ }
6144
+ async function inviteExternalUsersToChannel(client, input) {
6145
+ const invitedEmails = [];
6146
+ const alreadyInvitedEmails = [];
6147
+ for (const email of input.emails) {
6148
+ try {
6149
+ await client.api("conversations.inviteShared", {
6150
+ channel: input.channelId,
6151
+ emails: [email],
6152
+ external_limited: input.externalLimited ?? true
6153
+ });
6154
+ invitedEmails.push(email);
6155
+ } catch (err) {
6156
+ const message = err instanceof Error ? err.message : String(err);
6157
+ if (message.includes("already_in_channel") || message.includes("already_invited")) {
6158
+ alreadyInvitedEmails.push(email);
6159
+ continue;
6160
+ }
6161
+ throw err;
6162
+ }
6163
+ }
6164
+ return {
6165
+ invited_emails: invitedEmails,
6166
+ already_invited_emails: alreadyInvitedEmails
6167
+ };
6168
+ }
6169
+ function isLikelyEmail(input) {
6170
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input.trim());
6171
+ }
6172
+
6173
+ // src/cli/channel-command.ts
6174
+ function registerChannelCommand(input) {
6175
+ const channelCmd = input.program.command("channel").description("Create channels and invite users");
6176
+ channelCmd.command("new").description("Create a new channel").requiredOption("--name <name>", "Channel name").option("--private", "Create as a private channel").option("--workspace <url>", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)").action(async (...args) => {
6177
+ const [options] = args;
6178
+ try {
6179
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
6180
+ const payload = await input.ctx.withAutoRefresh({
6181
+ workspaceUrl,
6182
+ work: async () => {
6183
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
6184
+ return await createChannel(client, {
6185
+ name: options.name,
6186
+ isPrivate: Boolean(options.private)
6187
+ });
6188
+ }
6189
+ });
6190
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
6191
+ } catch (err) {
6192
+ console.error(input.ctx.errorMessage(err));
6193
+ process.exitCode = 1;
6194
+ }
6195
+ });
6196
+ channelCmd.command("invite").description("Invite users to a channel").requiredOption("--channel <id-or-name>", "Channel id/name (#general, general, C...)").requiredOption("--users <users>", "Comma-separated users (U..., @handle, handle, email)").option("--external", "Send Slack Connect external invites (email targets only)").option("--allow-external-user-invites", "For --external invites, allow invitees to invite additional users").option("--workspace <url>", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)").action(async (...args) => {
6197
+ const [options] = args;
6198
+ try {
6199
+ if (options.allowExternalUserInvites && !options.external) {
6200
+ throw new Error("--allow-external-user-invites requires --external");
6201
+ }
6202
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
6203
+ await input.ctx.assertWorkspaceSpecifiedForChannelNames({
6204
+ workspaceUrl,
6205
+ channels: [options.channel]
6206
+ });
6207
+ const userInputs = parseInviteUsersCsv(options.users);
6208
+ if (userInputs.length === 0) {
6209
+ throw new Error('No users provided. Pass --users "U01...,@alice,bob@example.com"');
6210
+ }
6211
+ const payload = await input.ctx.withAutoRefresh({
6212
+ workspaceUrl,
6213
+ work: async () => {
6214
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
6215
+ const channelId = await resolveChannelId(client, options.channel);
6216
+ if (options.external) {
6217
+ const split = splitEmailsFromInviteTargets(userInputs);
6218
+ if (split.emails.length === 0) {
6219
+ throw new Error('External invites require email targets in --users, e.g. --users "alice@example.com,bob@example.com"');
6220
+ }
6221
+ const externalLimited = !options.allowExternalUserInvites;
6222
+ const inviteResult2 = await inviteExternalUsersToChannel(client, {
6223
+ channelId,
6224
+ emails: split.emails,
6225
+ externalLimited
6226
+ });
6227
+ return {
6228
+ channel_id: channelId,
6229
+ external: true,
6230
+ external_limited: externalLimited,
6231
+ invited_emails: inviteResult2.invited_emails,
6232
+ already_invited_emails: inviteResult2.already_invited_emails,
6233
+ invalid_external_targets: split.non_email_targets
6234
+ };
6235
+ }
6236
+ const resolvedUserIds = [];
6237
+ const unresolvedUsers = [];
6238
+ for (const userInput of userInputs) {
6239
+ const userId = await resolveUserId2(client, userInput);
6240
+ if (!userId) {
6241
+ unresolvedUsers.push(userInput);
6242
+ continue;
6243
+ }
6244
+ resolvedUserIds.push(userId);
6245
+ }
6246
+ const inviteResult = await inviteUsersToChannel(client, {
6247
+ channelId,
6248
+ userIds: resolvedUserIds
6249
+ });
6250
+ return {
6251
+ channel_id: channelId,
6252
+ invited_user_ids: inviteResult.invited_user_ids,
6253
+ already_in_channel_user_ids: inviteResult.already_in_channel_user_ids,
6254
+ unresolved_users: unresolvedUsers
6255
+ };
6256
+ }
6257
+ });
6258
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
6259
+ } catch (err) {
6260
+ console.error(input.ctx.errorMessage(err));
6261
+ process.exitCode = 1;
6262
+ }
6263
+ });
6264
+ }
6265
+
4503
6266
  // src/index.ts
4504
6267
  var program = new Command;
4505
6268
  program.name("agent-slack").description("Slack automation CLI for AI agents").version(getPackageVersion());
@@ -4510,6 +6273,7 @@ registerCanvasCommand({ program, ctx });
4510
6273
  registerSearchCommand({ program, ctx });
4511
6274
  registerUpdateCommand({ program });
4512
6275
  registerUserCommand({ program, ctx });
6276
+ registerChannelCommand({ program, ctx });
4513
6277
  program.parse(process.argv);
4514
6278
  if (!process.argv.slice(2).length) {
4515
6279
  program.outputHelp();
@@ -4519,5 +6283,5 @@ if (subcommand && subcommand !== "update") {
4519
6283
  backgroundUpdateCheck();
4520
6284
  }
4521
6285
 
4522
- //# debugId=6879354D2CF2337D64756E2164756E21
6286
+ //# debugId=5576104C616416B664756E2164756E21
4523
6287
  //# sourceMappingURL=index.js.map