From 8c38a7fee8bf4397a92f85787c807e872ff6ca49 Mon Sep 17 00:00:00 2001 From: Shadow Date: Sun, 4 Jan 2026 01:53:15 -0600 Subject: [PATCH] Slack: add some fixes and connect it all up --- docs/configuration.md | 3 +- docs/slack.md | 8 - package.json | 2 + pnpm-lock.yaml | 323 +++++++++++++++++- src/agents/clawdis-tools.ts | 2 + src/agents/pi-tools.test.ts | 8 + src/agents/pi-tools.ts | 15 +- src/agents/tools/slack-actions.ts | 2 - src/agents/tools/slack-schema.ts | 1 - src/auto-reply/chunk.test.ts | 5 +- src/cli/deps.ts | 3 + src/cli/program.ts | 10 +- src/commands/agent.ts | 42 ++- src/commands/onboard-providers.ts | 33 +- src/commands/onboard-types.ts | 1 + src/commands/send.test.ts | 20 ++ src/commands/send.ts | 28 ++ src/config/sessions.ts | 1 + src/config/types.ts | 2 +- src/config/zod-schema.ts | 2 +- src/cron/isolated-agent.ts | 39 +++ src/cron/types.ts | 1 + src/gateway/config-reload.ts | 6 + src/gateway/hooks-mapping.ts | 11 +- src/gateway/protocol/schema.ts | 1 + src/gateway/server-http.ts | 1 + src/gateway/server-methods/providers.ts | 51 +++ src/gateway/server-methods/send.ts | 17 + src/gateway/server-providers.ts | 118 +++++++ src/gateway/server.reload.test.ts | 8 + src/gateway/server.ts | 13 +- src/infra/heartbeat-runner.ts | 39 ++- src/slack/actions.ts | 3 +- src/slack/index.ts | 2 + src/slack/monitor.ts | 43 +-- src/slack/probe.ts | 59 ++++ src/slack/send.ts | 187 +++++++++++ src/slack/token.ts | 12 + ui/src/ui/app-render.ts | 14 + ui/src/ui/app.ts | 33 +- ui/src/ui/controllers/config.ts | 10 +- ui/src/ui/controllers/connections.ts | 29 +- ui/src/ui/types.ts | 32 ++ ui/src/ui/ui-types.ts | 2 - ui/src/ui/views/connections.ts | 415 +++++++++++++++++++++++- 45 files changed, 1568 insertions(+), 89 deletions(-) create mode 100644 src/slack/probe.ts create mode 100644 src/slack/send.ts create mode 100644 src/slack/token.ts diff --git a/docs/configuration.md b/docs/configuration.md index b0e806a6e..ade33dde8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -327,14 +327,13 @@ Slack runs in Socket Mode and requires both a bot token and app token: sessionPrefix: "slack:slash", ephemeral: true }, - replyToMode: "off", // off | first | all textChunkLimit: 4000, mediaMaxMb: 20 } } ``` -Clawdis starts Slack only when a `slack` config section exists and both tokens are set (unless `slack.enabled` is `false`). Provide `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` env vars if you prefer. Use `user:` (DM) or `channel:` when specifying delivery targets for cron/CLI commands. +Clawdis starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:` (DM) or `channel:` when specifying delivery targets for cron/CLI commands. Reaction notification modes: - `off`: no reaction events. diff --git a/docs/slack.md b/docs/slack.md index e4150c4a4..22f6654ba 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -121,7 +121,6 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: "sessionPrefix": "slack:slash", "ephemeral": true }, - "replyToMode": "off", "textChunkLimit": 4000, "mediaMaxMb": 20 } @@ -137,13 +136,6 @@ Tokens can also be supplied via env vars: - Channels map to `slack:channel:` sessions. - Slash commands use `slack:slash:` sessions. -## Reply threading -Slack replies can be threaded when reply tags are present and `slack.replyToMode` is enabled. - -```json -{ "slack": { "replyToMode": "first" } } -``` - ## Delivery targets Use these with cron/CLI sends: - `user:` for DMs diff --git a/package.json b/package.json index 8ce251344..0157fdbf8 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,8 @@ "@mariozechner/pi-ai": "^0.32.3", "@mariozechner/pi-coding-agent": "^0.32.3", "@mariozechner/pi-tui": "^0.32.3", + "@slack/bolt": "^4.5.0", + "@slack/web-api": "^7.11.1", "@sinclair/typebox": "0.34.46", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.17.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a4b240b0..36128c3e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,12 @@ importers: '@sinclair/typebox': specifier: 0.34.46 version: 0.34.46 + '@slack/bolt': + specifier: ^4.5.0 + version: 4.6.0(@types/express@5.0.6) + '@slack/web-api': + specifier: ^7.11.1 + version: 7.13.0 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) @@ -1098,6 +1104,32 @@ packages: '@sinclair/typebox@0.34.46': resolution: {integrity: sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==} + '@slack/bolt@4.6.0': + resolution: {integrity: sha512-xPgfUs2+OXSugz54Ky07pA890+Qydk22SYToi8uGpXeHSt1JWwFJkRyd/9Vlg5I1AdfdpGXExDpwnbuN9Q/2dQ==} + engines: {node: '>=18', npm: '>=8.6.0'} + peerDependencies: + '@types/express': ^5.0.0 + + '@slack/logger@4.0.0': + resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/oauth@3.0.4': + resolution: {integrity: sha512-+8H0g7mbrHndEUbYCP7uYyBCbwqmm3E6Mo3nfsDvZZW74zKk1ochfH/fWSvGInYNCVvaBUbg3RZBbTp0j8yJCg==} + engines: {node: '>=18', npm: '>=8.6.0'} + + '@slack/socket-mode@2.0.5': + resolution: {integrity: sha512-VaapvmrAifeFLAFaDPfGhEwwunTKsI6bQhYzxRXw7BSujZUae5sANO76WqlVsLXuhVtCVrBWPiS2snAQR2RHJQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.19.0': + resolution: {integrity: sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.13.0': + resolution: {integrity: sha512-ERcExbWrnkDN8ovoWWe6Wgt/usanj1dWUd18dJLpctUI4mlPS0nKt81Joh8VI+OPbNnY1lIilVt9gdMBD9U2ig==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1156,6 +1188,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -1171,6 +1206,9 @@ packages: '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} @@ -1186,6 +1224,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -1360,6 +1401,9 @@ packages: async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1374,6 +1418,9 @@ packages: resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==} engines: {node: '>=14'} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1489,6 +1536,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} @@ -1543,6 +1594,10 @@ packages: supports-color: optional: true + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1621,6 +1676,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -1644,6 +1703,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -1700,10 +1762,23 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -1800,6 +1875,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hashery@1.4.0: resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==} engines: {node: '>=20'} @@ -1853,6 +1932,9 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1872,6 +1954,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-url@1.2.4: resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} @@ -1935,6 +2021,10 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -2036,6 +2126,27 @@ packages: lit@3.3.2: resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} @@ -2115,10 +2226,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -2164,9 +2283,6 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2250,10 +2366,26 @@ packages: oxlint-tsgolint: optional: true + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + p-queue@9.0.1: resolution: {integrity: sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==} engines: {node: '>=20'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + p-timeout@7.0.1: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} @@ -2377,6 +2509,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -2450,6 +2585,10 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2697,6 +2836,10 @@ packages: resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==} engines: {node: '>=16'} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -3626,6 +3769,71 @@ snapshots: '@sinclair/typebox@0.34.46': {} + '@slack/bolt@4.6.0(@types/express@5.0.6)': + dependencies: + '@slack/logger': 4.0.0 + '@slack/oauth': 3.0.4 + '@slack/socket-mode': 2.0.5 + '@slack/types': 2.19.0 + '@slack/web-api': 7.13.0 + '@types/express': 5.0.6 + axios: 1.13.2 + express: 5.2.1 + path-to-regexp: 8.3.0 + raw-body: 3.0.2 + tsscmp: 1.0.6 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@slack/logger@4.0.0': + dependencies: + '@types/node': 25.0.3 + + '@slack/oauth@3.0.4': + dependencies: + '@slack/logger': 4.0.0 + '@slack/web-api': 7.13.0 + '@types/jsonwebtoken': 9.0.10 + '@types/node': 25.0.3 + jsonwebtoken: 9.0.3 + transitivePeerDependencies: + - debug + + '@slack/socket-mode@2.0.5': + dependencies: + '@slack/logger': 4.0.0 + '@slack/web-api': 7.13.0 + '@types/node': 25.0.3 + '@types/ws': 8.18.1 + eventemitter3: 5.0.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + + '@slack/types@2.19.0': {} + + '@slack/web-api@7.13.0': + dependencies: + '@slack/logger': 4.0.0 + '@slack/types': 2.19.0 + '@types/node': 25.0.3 + '@types/retry': 0.12.0 + axios: 1.13.2 + eventemitter3: 5.0.1 + form-data: 4.0.5 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + '@standard-schema/spec@1.1.0': {} '@testing-library/dom@10.4.1': @@ -3703,6 +3911,11 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.0.3 + '@types/linkify-it@5.0.0': {} '@types/long@4.0.2': {} @@ -3716,6 +3929,8 @@ snapshots: '@types/mime-types@2.1.4': {} + '@types/ms@2.1.0': {} + '@types/node@10.17.60': {} '@types/node@25.0.3': @@ -3728,6 +3943,8 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/retry@0.12.0': {} + '@types/send@1.2.1': dependencies: '@types/node': 25.0.3 @@ -3961,6 +4178,8 @@ snapshots: dependencies: tslib: 2.8.1 + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} audio-buffer@5.0.0: @@ -3981,6 +4200,14 @@ snapshots: audio-type@2.2.1: optional: true + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} balanced-match@3.0.1: {} @@ -4113,6 +4340,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.2: {} commander@8.3.0: {} @@ -4149,6 +4380,8 @@ snapshots: dependencies: ms: 2.1.3 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: @@ -4222,6 +4455,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -4263,6 +4503,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -4353,11 +4595,21 @@ snapshots: transitivePeerDependencies: - supports-color + follow-redirects@1.15.11: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -4478,6 +4730,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hashery@1.4.0: dependencies: hookified: 1.14.0 @@ -4527,6 +4783,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-electron@2.2.2: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -4539,6 +4797,8 @@ snapshots: is-promise@4.0.0: {} + is-stream@2.0.1: {} + is-url@1.2.4: {} isarray@1.0.0: {} @@ -4600,6 +4860,19 @@ snapshots: jsonc-parser@3.3.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -4700,6 +4973,20 @@ snapshots: lit-element: 4.2.2 lit-html: 3.3.2 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash.snakecase@4.1.1: {} lodash@4.17.21: {} @@ -4763,8 +5050,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -4814,8 +5107,6 @@ snapshots: negotiator@1.0.0: {} - node-addon-api@7.1.1: {} - node-domexception@1.0.0: {} node-fetch@2.7.0: @@ -4892,11 +5183,27 @@ snapshots: '@oxlint/win32-x64': 1.36.0 oxlint-tsgolint: 0.10.1 + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + p-queue@9.0.1: dependencies: eventemitter3: 5.0.1 p-timeout: 7.0.1 + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + p-timeout@7.0.1: {} package-json-from-dist@1.0.1: {} @@ -5036,6 +5343,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + punycode.js@2.3.1: {} qified@0.5.3: @@ -5122,6 +5431,8 @@ snapshots: retry@0.12.0: {} + retry@0.13.1: {} + reusify@1.1.0: {} rimraf@5.0.10: @@ -5432,6 +5743,8 @@ snapshots: tslog@4.10.2: {} + tsscmp@1.0.6: {} + tsx@4.21.0: dependencies: esbuild: 0.27.2 diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 7c976983e..05fb3b1c6 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -8,6 +8,7 @@ import { createNodesTool } from "./tools/nodes-tool.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; +import { createSlackTool } from "./tools/slack-tool.js"; export function createClawdisTools(options?: { browserControlUrl?: string; @@ -20,6 +21,7 @@ export function createClawdisTools(options?: { createNodesTool(), createCronTool(), createDiscordTool(), + createSlackTool(), createGatewayTool(), createSessionsListTool(), createSessionsHistoryTool(), diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index e8adf0d37..464b52df8 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -88,6 +88,14 @@ describe("createClawdisCodingTools", () => { expect(discord.some((tool) => tool.name === "discord")).toBe(true); }); + it("scopes slack tool to slack surface", () => { + const other = createClawdisCodingTools({ surface: "whatsapp" }); + expect(other.some((tool) => tool.name === "slack")).toBe(false); + + const slack = createClawdisCodingTools({ surface: "slack" }); + expect(slack.some((tool) => tool.name === "slack")).toBe(true); + }); + it("keeps read tool image metadata intact", async () => { const tools = createClawdisCodingTools(); const readTool = tools.find((tool) => tool.name === "read"); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index d1e7aa7fa..cb578edbb 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -441,6 +441,12 @@ function shouldIncludeDiscordTool(surface?: string): boolean { return normalized === "discord" || normalized.startsWith("discord:"); } +function shouldIncludeSlackTool(surface?: string): boolean { + const normalized = normalizeSurface(surface); + if (!normalized) return false; + return normalized === "slack" || normalized.startsWith("slack:"); +} + export function createClawdisCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; surface?: string; @@ -494,9 +500,12 @@ export function createClawdisCodingTools(options?: { }), ]; const allowDiscord = shouldIncludeDiscordTool(options?.surface); - const filtered = allowDiscord - ? tools - : tools.filter((tool) => tool.name !== "discord"); + const allowSlack = shouldIncludeSlackTool(options?.surface); + const filtered = tools.filter((tool) => { + if (tool.name === "discord") return allowDiscord; + if (tool.name === "slack") return allowSlack; + return true; + }); const sandboxed = sandbox ? filterToolsByPolicy(filtered, sandbox.tools) : filtered; diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index e726b710c..2c1610cff 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -66,10 +66,8 @@ export async function handleSlackAction( const to = readStringParam(params, "to", { required: true }); const content = readStringParam(params, "content", { required: true }); const mediaUrl = readStringParam(params, "mediaUrl"); - const replyTo = readStringParam(params, "replyTo"); const result = await sendSlackMessage(to, content, { mediaUrl: mediaUrl ?? undefined, - replyTo: replyTo ?? undefined, }); return jsonResult({ ok: true, result }); } diff --git a/src/agents/tools/slack-schema.ts b/src/agents/tools/slack-schema.ts index 46fed8e25..84b3a715c 100644 --- a/src/agents/tools/slack-schema.ts +++ b/src/agents/tools/slack-schema.ts @@ -17,7 +17,6 @@ export const SlackToolSchema = Type.Union([ to: Type.String(), content: Type.String(), mediaUrl: Type.Optional(Type.String()), - replyTo: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("editMessage"), diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 02f6d75cb..8bef8ad71 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -63,7 +63,10 @@ describe("resolveTextChunkLimit", () => { }); it("uses the matching provider override", () => { - const cfg = { discord: { textChunkLimit: 111 }, slack: { textChunkLimit: 222 } }; + const cfg = { + discord: { textChunkLimit: 111 }, + slack: { textChunkLimit: 222 }, + }; expect(resolveTextChunkLimit(cfg, "discord")).toBe(111); expect(resolveTextChunkLimit(cfg, "slack")).toBe(222); expect(resolveTextChunkLimit(cfg, "telegram")).toBe(4000); diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 441617377..41a1118d0 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -2,12 +2,14 @@ import { sendMessageDiscord } from "../discord/send.js"; import { sendMessageIMessage } from "../imessage/send.js"; import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js"; import { sendMessageSignal } from "../signal/send.js"; +import { sendMessageSlack } from "../slack/send.js"; import { sendMessageTelegram } from "../telegram/send.js"; export type CliDeps = { sendMessageWhatsApp: typeof sendMessageWhatsApp; sendMessageTelegram: typeof sendMessageTelegram; sendMessageDiscord: typeof sendMessageDiscord; + sendMessageSlack: typeof sendMessageSlack; sendMessageSignal: typeof sendMessageSignal; sendMessageIMessage: typeof sendMessageIMessage; }; @@ -17,6 +19,7 @@ export function createDefaultDeps(): CliDeps { sendMessageWhatsApp, sendMessageTelegram, sendMessageDiscord, + sendMessageSlack, sendMessageSignal, sendMessageIMessage, }; diff --git a/src/cli/program.ts b/src/cli/program.ts index bd44236bb..1d127ba4c 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -300,7 +300,7 @@ export function buildProgram() { program .command("send") .description( - "Send a message (WhatsApp Web, Telegram bot, Discord, Signal, iMessage)", + "Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)", ) .requiredOption( "-t, --to ", @@ -318,7 +318,7 @@ export function buildProgram() { ) .option( "--provider ", - "Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)", + "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", ) .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) @@ -361,7 +361,7 @@ Examples: .option("--verbose ", "Persist agent verbose level for the session") .option( "--provider ", - "Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)", + "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", ) .option( "--deliver", @@ -411,7 +411,7 @@ Examples: .option("--json", "Output JSON instead of text", false) .option( "--deep", - "Probe providers (WhatsApp Web + Telegram + Discord + Signal)", + "Probe providers (WhatsApp Web + Telegram + Discord + Slack + Signal)", false, ) .option("--timeout ", "Probe timeout in milliseconds", "10000") @@ -422,7 +422,7 @@ Examples: Examples: clawdis status # show linked account + session store summary clawdis status --json # machine-readable output - clawdis status --deep # run provider probes (WA + Telegram + Discord + Signal) + clawdis status --deep # run provider probes (WA + Telegram + Discord + Slack + Signal) clawdis status --deep --timeout 5000 # tighten probe timeout`, ) .action(async (opts) => { diff --git a/src/commands/agent.ts b/src/commands/agent.ts index a9490dd2c..af0a33032 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -473,6 +473,7 @@ export async function agentCommand( const whatsappTarget = opts.to ? normalizeE164(opts.to) : allowFrom[0]; const telegramTarget = opts.to?.trim() || undefined; const discordTarget = opts.to?.trim() || undefined; + const slackTarget = opts.to?.trim() || undefined; const signalTarget = opts.to?.trim() || undefined; const imessageTarget = opts.to?.trim() || undefined; @@ -484,11 +485,13 @@ export async function agentCommand( ? whatsappTarget : deliveryProvider === "discord" ? discordTarget - : deliveryProvider === "signal" - ? signalTarget - : deliveryProvider === "imessage" - ? imessageTarget - : undefined; + : deliveryProvider === "slack" + ? slackTarget + : deliveryProvider === "signal" + ? signalTarget + : deliveryProvider === "imessage" + ? imessageTarget + : undefined; const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; runtime.error?.(message); if (!runtime.error) runtime.log(message); @@ -514,6 +517,13 @@ export async function agentCommand( if (!bestEffortDeliver) throw err; logDeliveryError(err); } + if (deliveryProvider === "slack" && !slackTarget) { + const err = new Error( + "Delivering to Slack requires --to ", + ); + if (!bestEffortDeliver) throw err; + logDeliveryError(err); + } if (deliveryProvider === "signal" && !signalTarget) { const err = new Error( "Delivering to Signal requires --to ", @@ -539,6 +549,7 @@ export async function agentCommand( deliveryProvider !== "whatsapp" && deliveryProvider !== "telegram" && deliveryProvider !== "discord" && + deliveryProvider !== "slack" && deliveryProvider !== "signal" && deliveryProvider !== "imessage" && deliveryProvider !== "webchat" @@ -574,6 +585,7 @@ export async function agentCommand( deliveryProvider === "whatsapp" || deliveryProvider === "telegram" || deliveryProvider === "discord" || + deliveryProvider === "slack" || deliveryProvider === "signal" || deliveryProvider === "imessage" ? resolveTextChunkLimit(cfg, deliveryProvider) @@ -666,6 +678,26 @@ export async function agentCommand( } } + if (deliveryProvider === "slack" && slackTarget) { + try { + if (media.length === 0) { + await deps.sendMessageSlack(slackTarget, text); + } else { + let first = true; + for (const url of media) { + const caption = first ? text : ""; + first = false; + await deps.sendMessageSlack(slackTarget, caption, { + mediaUrl: url, + }); + } + } + } catch (err) { + if (!bestEffortDeliver) throw err; + logDeliveryError(err); + } + } + if (deliveryProvider === "signal" && signalTarget) { try { if (media.length === 0) { diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 9af615beb..4dae69f17 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -31,6 +31,7 @@ async function noteProviderPrimer(prompter: WizardPrompter): Promise { "WhatsApp: dedicated second number recommended; primary number OK (self-chat).", "Telegram: Bot API (token from @BotFather), replies via your bot.", "Discord: Bot token from Discord Developer Portal; invite bot to your server.", + "Slack: Socket Mode app token + bot token, DMs via App Home Messages tab.", "Signal: signal-cli as a linked device; separate number recommended.", "iMessage: local imsg CLI; separate Apple ID recommended only on a separate Mac.", ].join("\n"), @@ -74,6 +75,10 @@ function buildSlackManifest(botName: string) { display_name: safeName, always_online: false, }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, slash_commands: [ { command: "/clawd", @@ -94,6 +99,7 @@ function buildSlackManifest(botName: string) { "users:read", "app_mentions:read", "reactions:read", + "reactions:write", "pins:read", "pins:write", "emoji:read", @@ -137,6 +143,7 @@ async function noteSlackTokenHelp( "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", "3) OAuth & Permissions → install app to workspace (xoxb- bot token)", "4) Enable Event Subscriptions (socket) for message events", + "5) App Home → enable the Messages tab for DMs", "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", "", "Manifest (JSON):", @@ -237,10 +244,16 @@ export async function setupProviders( const whatsappLinked = await detectWhatsAppLinked(); const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim()); + const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim()); + const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim()); const telegramConfigured = Boolean( telegramEnv || cfg.telegram?.botToken || cfg.telegram?.tokenFile, ); const discordConfigured = Boolean(discordEnv || cfg.discord?.token); + const slackConfigured = Boolean( + (slackBotEnv && slackAppEnv) || + (cfg.slack?.botToken && cfg.slack?.appToken), + ); const signalConfigured = Boolean( cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort, ); @@ -257,6 +270,7 @@ export async function setupProviders( `WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`, `Telegram: ${telegramConfigured ? "configured" : "needs token"}`, `Discord: ${discordConfigured ? "configured" : "needs token"}`, + `Slack: ${slackConfigured ? "configured" : "needs tokens"}`, `Signal: ${signalConfigured ? "configured" : "needs setup"}`, `iMessage: ${imessageConfigured ? "configured" : "needs setup"}`, `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, @@ -291,6 +305,11 @@ export async function setupProviders( label: "Discord (Bot API)", hint: discordConfigured ? "configured" : "needs token", }, + { + value: "slack", + label: "Slack (Socket Mode)", + hint: slackConfigured ? "configured" : "needs tokens", + }, { value: "signal", label: "Signal (signal-cli)", @@ -695,6 +714,19 @@ export async function setupProviders( } } + if (!selection.includes("slack") && slackConfigured) { + const disable = await prompter.confirm({ + message: "Disable Slack provider?", + initialValue: false, + }); + if (disable) { + next = { + ...next, + slack: { ...next.slack, enabled: false }, + }; + } + } + if (!selection.includes("signal") && signalConfigured) { const disable = await prompter.confirm({ message: "Disable Signal provider?", @@ -724,4 +756,3 @@ export async function setupProviders( return next; } - diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index a23caf9c8..d61468066 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -14,6 +14,7 @@ export type ProviderChoice = | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index 2400aa659..5fd946a4b 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -41,6 +41,7 @@ const makeDeps = (overrides: Partial = {}): CliDeps => ({ sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), sendMessageDiscord: vi.fn(), + sendMessageSlack: vi.fn(), sendMessageSignal: vi.fn(), sendMessageIMessage: vi.fn(), ...overrides, @@ -173,6 +174,25 @@ describe("sendCommand", () => { expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); + it("routes to slack provider", async () => { + const deps = makeDeps({ + sendMessageSlack: vi + .fn() + .mockResolvedValue({ messageId: "s1", channelId: "C123" }), + }); + await sendCommand( + { to: "channel:C123", message: "hi", provider: "slack" }, + deps, + runtime, + ); + expect(deps.sendMessageSlack).toHaveBeenCalledWith( + "channel:C123", + "hi", + expect.objectContaining({ mediaUrl: undefined }), + ); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + }); + it("routes to imessage provider", async () => { const deps = makeDeps({ sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }), diff --git a/src/commands/send.ts b/src/commands/send.ts index f8ec0e4a1..39db2462b 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -86,6 +86,34 @@ export async function sendCommand( return; } + if (provider === "slack") { + const result = await deps.sendMessageSlack(opts.to, opts.message, { + mediaUrl: opts.media, + }); + runtime.log( + success( + `✅ Sent via slack. Message ID: ${result.messageId} (channel ${result.channelId})`, + ), + ); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider: "slack", + via: "direct", + to: opts.to, + channelId: result.channelId, + messageId: result.messageId, + mediaUrl: opts.media ?? null, + }, + null, + 2, + ), + ); + } + return; + } + if (provider === "signal") { const result = await deps.sendMessageSignal(opts.to, opts.message, { mediaUrl: opts.media, diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 5e7d0721a..647a9ebad 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -61,6 +61,7 @@ export type SessionEntry = { | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage" | "webchat"; diff --git a/src/config/types.ts b/src/config/types.ts index f2f3d6d2d..0f497ed6c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -65,6 +65,7 @@ export type AgentElevatedAllowFromConfig = { whatsapp?: string[]; telegram?: Array; discord?: Array; + slack?: Array; signal?: Array; imessage?: Array; webchat?: Array; @@ -337,7 +338,6 @@ export type SlackConfig = { botToken?: string; appToken?: string; textChunkLimit?: number; - replyToMode?: ReplyToMode; mediaMaxMb?: number; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ reactionNotifications?: SlackReactionNotificationMode; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0aa3ddc07..692344640 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -417,6 +417,7 @@ export const ClawdisSchema = z.object({ whatsapp: z.array(z.string()).optional(), telegram: z.array(z.union([z.string(), z.number()])).optional(), discord: z.array(z.union([z.string(), z.number()])).optional(), + slack: z.array(z.union([z.string(), z.number()])).optional(), signal: z.array(z.union([z.string(), z.number()])).optional(), imessage: z.array(z.union([z.string(), z.number()])).optional(), webchat: z.array(z.union([z.string(), z.number()])).optional(), @@ -628,7 +629,6 @@ export const ClawdisSchema = z.object({ botToken: z.string().optional(), appToken: z.string().optional(), textChunkLimit: z.number().int().positive().optional(), - replyToMode: ReplyToModeSchema.optional(), mediaMaxMb: z.number().positive().optional(), reactionNotifications: z .enum(["off", "own", "all", "allowlist"]) diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 7b9fabbf2..ada657638 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -64,6 +64,7 @@ function resolveDeliveryTarget( | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; to?: string; @@ -92,6 +93,7 @@ function resolveDeliveryTarget( requestedChannel === "whatsapp" || requestedChannel === "telegram" || requestedChannel === "discord" || + requestedChannel === "slack" || requestedChannel === "signal" || requestedChannel === "imessage" ) { @@ -447,6 +449,43 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } + } else if (resolvedDelivery.channel === "slack") { + if (!resolvedDelivery.to) { + if (!bestEffortDeliver) + return { + status: "error", + summary, + error: + "Cron delivery to Slack requires --channel slack and --to ", + }; + return { + status: "skipped", + summary: "Delivery skipped (no Slack destination).", + }; + } + const slackTarget = resolvedDelivery.to; + try { + for (const payload of payloads) { + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + if (mediaList.length === 0) { + await params.deps.sendMessageSlack(slackTarget, payload.text ?? ""); + } else { + let first = true; + for (const url of mediaList) { + const caption = first ? (payload.text ?? "") : ""; + first = false; + await params.deps.sendMessageSlack(slackTarget, caption, { + mediaUrl: url, + }); + } + } + } + } catch (err) { + if (!bestEffortDeliver) + return { status: "error", summary, error: String(err) }; + return { status: "ok", summary }; + } } else if (resolvedDelivery.channel === "signal") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) diff --git a/src/cron/types.ts b/src/cron/types.ts index ab1cf99e7..a01479b8a 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -19,6 +19,7 @@ export type CronPayload = | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; to?: string; diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index bcc4fc826..39c76e135 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -15,6 +15,7 @@ export type ProviderKind = | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; @@ -47,6 +48,7 @@ type ReloadAction = | "restart-provider:whatsapp" | "restart-provider:telegram" | "restart-provider:discord" + | "restart-provider:slack" | "restart-provider:signal" | "restart-provider:imessage"; @@ -70,6 +72,7 @@ const RELOAD_RULES: ReloadRule[] = [ { prefix: "web", kind: "hot", actions: ["restart-provider:whatsapp"] }, { prefix: "telegram", kind: "hot", actions: ["restart-provider:telegram"] }, { prefix: "discord", kind: "hot", actions: ["restart-provider:discord"] }, + { prefix: "slack", kind: "hot", actions: ["restart-provider:slack"] }, { prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] }, { prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] }, { prefix: "identity", kind: "none" }, @@ -200,6 +203,9 @@ export function buildGatewayReloadPlan( case "restart-provider:discord": plan.restartProviders.add("discord"); break; + case "restart-provider:slack": + plan.restartProviders.add("slack"); + break; case "restart-provider:signal": plan.restartProviders.add("signal"); break; diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 1b9fee656..64ea10d98 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -23,6 +23,7 @@ export type HookMappingResolved = { | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; to?: string; @@ -61,6 +62,7 @@ export type HookAction = | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; to?: string; @@ -99,7 +101,14 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + channel: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to: string; thinking: string; timeoutSeconds: number; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 09e43e892..33e159f81 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -635,6 +635,7 @@ export const CronPayloadSchema = Type.Union([ Type.Literal("whatsapp"), Type.Literal("telegram"), Type.Literal("discord"), + Type.Literal("slack"), ]), ), to: Type.Optional(Type.String()), diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 9fbdfe114..69ac71516 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -37,6 +37,7 @@ type HookDispatchers = { | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; to?: string; diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts index fbc272d41..a7cd52cfc 100644 --- a/src/gateway/server-methods/providers.ts +++ b/src/gateway/server-methods/providers.ts @@ -8,6 +8,11 @@ import { type DiscordProbe, probeDiscord } from "../../discord/probe.js"; import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js"; import { webAuthExists } from "../../providers/web/index.js"; import { probeSignal, type SignalProbe } from "../../signal/probe.js"; +import { probeSlack, type SlackProbe } from "../../slack/probe.js"; +import { + resolveSlackAppToken, + resolveSlackBotToken, +} from "../../slack/token.js"; import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js"; import { resolveTelegramToken } from "../../telegram/token.js"; import { getWebAuthAgeMs, readWebSelfId } from "../../web/session.js"; @@ -74,6 +79,41 @@ export const providersHandlers: GatewayRequestHandlers = { discordLastProbeAt = Date.now(); } + const slackCfg = cfg.slack; + const slackEnabled = slackCfg?.enabled !== false; + const slackBotEnvToken = slackEnabled + ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) + : undefined; + const slackBotConfigToken = slackEnabled + ? resolveSlackBotToken(slackCfg?.botToken) + : undefined; + const slackBotToken = slackBotEnvToken ?? slackBotConfigToken ?? ""; + const slackBotTokenSource = slackBotEnvToken + ? "env" + : slackBotConfigToken + ? "config" + : "none"; + const slackAppEnvToken = slackEnabled + ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) + : undefined; + const slackAppConfigToken = slackEnabled + ? resolveSlackAppToken(slackCfg?.appToken) + : undefined; + const slackAppToken = slackAppEnvToken ?? slackAppConfigToken ?? ""; + const slackAppTokenSource = slackAppEnvToken + ? "env" + : slackAppConfigToken + ? "config" + : "none"; + const slackConfigured = + slackEnabled && Boolean(slackBotToken) && Boolean(slackAppToken); + let slackProbe: SlackProbe | undefined; + let slackLastProbeAt: number | null = null; + if (probe && slackConfigured) { + slackProbe = await probeSlack(slackBotToken, timeoutMs); + slackLastProbeAt = Date.now(); + } + const signalCfg = cfg.signal; const signalEnabled = signalCfg?.enabled !== false; const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1"; @@ -152,6 +192,17 @@ export const providersHandlers: GatewayRequestHandlers = { probe: discordProbe, lastProbeAt: discordLastProbeAt, }, + slack: { + configured: slackConfigured, + botTokenSource: slackBotTokenSource, + appTokenSource: slackAppTokenSource, + running: runtime.slack.running, + lastStartAt: runtime.slack.lastStartAt ?? null, + lastStopAt: runtime.slack.lastStopAt ?? null, + lastError: runtime.slack.lastError ?? null, + probe: slackProbe, + lastProbeAt: slackLastProbeAt, + }, signal: { configured: signalConfigured, baseUrl: signalBaseUrl, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index f8557537f..07ebf4cdb 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -3,6 +3,7 @@ import { sendMessageDiscord } from "../../discord/index.js"; import { shouldLogVerbose } from "../../globals.js"; import { sendMessageIMessage } from "../../imessage/index.js"; import { sendMessageSignal } from "../../signal/index.js"; +import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; import { sendMessageWhatsApp } from "../../web/outbound.js"; @@ -87,6 +88,22 @@ export const sendHandlers: GatewayRequestHandlers = { payload, }); respond(true, payload, undefined, { provider }); + } else if (provider === "slack") { + const result = await sendMessageSlack(to, message, { + mediaUrl: request.mediaUrl, + }); + const payload = { + runId: idem, + messageId: result.messageId, + channelId: result.channelId, + provider, + }; + context.dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); } else if (provider === "signal") { const cfg = loadConfig(); const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index 09db77964..c92e8c056 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -7,6 +7,11 @@ import type { createSubsystemLogger } from "../logging.js"; import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; import type { RuntimeEnv } from "../runtime.js"; import { monitorSignalProvider } from "../signal/index.js"; +import { + monitorSlackProvider, + resolveSlackAppToken, + resolveSlackBotToken, +} from "../slack/index.js"; import { monitorTelegramProvider } from "../telegram/monitor.js"; import { probeTelegram } from "../telegram/probe.js"; import { resolveTelegramToken } from "../telegram/token.js"; @@ -29,6 +34,13 @@ export type DiscordRuntimeStatus = { lastError?: string | null; }; +export type SlackRuntimeStatus = { + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; +}; + export type SignalRuntimeStatus = { running: boolean; lastStartAt?: number | null; @@ -50,6 +62,7 @@ export type ProviderRuntimeSnapshot = { whatsapp: WebProviderStatus; telegram: TelegramRuntimeStatus; discord: DiscordRuntimeStatus; + slack: SlackRuntimeStatus; signal: SignalRuntimeStatus; imessage: IMessageRuntimeStatus; }; @@ -61,11 +74,13 @@ type ProviderManagerOptions = { logWhatsApp: SubsystemLogger; logTelegram: SubsystemLogger; logDiscord: SubsystemLogger; + logSlack: SubsystemLogger; logSignal: SubsystemLogger; logIMessage: SubsystemLogger; whatsappRuntimeEnv: RuntimeEnv; telegramRuntimeEnv: RuntimeEnv; discordRuntimeEnv: RuntimeEnv; + slackRuntimeEnv: RuntimeEnv; signalRuntimeEnv: RuntimeEnv; imessageRuntimeEnv: RuntimeEnv; }; @@ -79,6 +94,8 @@ export type ProviderManager = { stopTelegramProvider: () => Promise; startDiscordProvider: () => Promise; stopDiscordProvider: () => Promise; + startSlackProvider: () => Promise; + stopSlackProvider: () => Promise; startSignalProvider: () => Promise; stopSignalProvider: () => Promise; startIMessageProvider: () => Promise; @@ -94,11 +111,13 @@ export function createProviderManager( logWhatsApp, logTelegram, logDiscord, + logSlack, logSignal, logIMessage, whatsappRuntimeEnv, telegramRuntimeEnv, discordRuntimeEnv, + slackRuntimeEnv, signalRuntimeEnv, imessageRuntimeEnv, } = opts; @@ -106,11 +125,13 @@ export function createProviderManager( let whatsappAbort: AbortController | null = null; let telegramAbort: AbortController | null = null; let discordAbort: AbortController | null = null; + let slackAbort: AbortController | null = null; let signalAbort: AbortController | null = null; let imessageAbort: AbortController | null = null; let whatsappTask: Promise | null = null; let telegramTask: Promise | null = null; let discordTask: Promise | null = null; + let slackTask: Promise | null = null; let signalTask: Promise | null = null; let imessageTask: Promise | null = null; @@ -137,6 +158,12 @@ export function createProviderManager( lastStopAt: null, lastError: null, }; + let slackRuntime: SlackRuntimeStatus = { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }; let signalRuntime: SignalRuntimeStatus = { running: false, lastStartAt: null, @@ -432,6 +459,93 @@ export function createProviderManager( }; }; + const startSlackProvider = async () => { + if (slackTask) return; + const cfg = loadConfig(); + if (cfg.slack?.enabled === false) { + slackRuntime = { + ...slackRuntime, + running: false, + lastError: "disabled", + }; + if (shouldLogVerbose()) { + logSlack.debug("slack provider disabled (slack.enabled=false)"); + } + return; + } + const botToken = resolveSlackBotToken( + process.env.SLACK_BOT_TOKEN ?? cfg.slack?.botToken ?? undefined, + ); + const appToken = resolveSlackAppToken( + process.env.SLACK_APP_TOKEN ?? cfg.slack?.appToken ?? undefined, + ); + if (!botToken || !appToken) { + slackRuntime = { + ...slackRuntime, + running: false, + lastError: "not configured", + }; + if (shouldLogVerbose()) { + logSlack.debug( + "slack provider not configured (missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN)", + ); + } + return; + } + logSlack.info( + `starting provider${cfg.slack ? "" : " (no slack config; tokens via env)"}`, + ); + slackAbort = new AbortController(); + slackRuntime = { + ...slackRuntime, + running: true, + lastStartAt: Date.now(), + lastError: null, + }; + const task = monitorSlackProvider({ + botToken, + appToken, + runtime: slackRuntimeEnv, + abortSignal: slackAbort.signal, + mediaMaxMb: cfg.slack?.mediaMaxMb, + slashCommand: cfg.slack?.slashCommand, + }) + .catch((err) => { + slackRuntime = { + ...slackRuntime, + lastError: formatError(err), + }; + logSlack.error(`provider exited: ${formatError(err)}`); + }) + .finally(() => { + slackAbort = null; + slackTask = null; + slackRuntime = { + ...slackRuntime, + running: false, + lastStopAt: Date.now(), + }; + }); + slackTask = task; + }; + + const stopSlackProvider = async () => { + if (!slackAbort && !slackTask) return; + slackAbort?.abort(); + try { + await slackTask; + } catch { + // ignore + } + slackAbort = null; + slackTask = null; + slackRuntime = { + ...slackRuntime, + running: false, + lastStopAt: Date.now(), + }; + }; + const startSignalProvider = async () => { if (signalTask) return; const cfg = loadConfig(); @@ -634,6 +748,7 @@ export function createProviderManager( const startProviders = async () => { await startWhatsAppProvider(); await startDiscordProvider(); + await startSlackProvider(); await startTelegramProvider(); await startSignalProvider(); await startIMessageProvider(); @@ -652,6 +767,7 @@ export function createProviderManager( whatsapp: { ...whatsappRuntime }, telegram: { ...telegramRuntime }, discord: { ...discordRuntime }, + slack: { ...slackRuntime }, signal: { ...signalRuntime }, imessage: { ...imessageRuntime }, }); @@ -665,6 +781,8 @@ export function createProviderManager( stopTelegramProvider, startDiscordProvider, stopDiscordProvider, + startSlackProvider, + stopSlackProvider, startSignalProvider, stopSignalProvider, startIMessageProvider, diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 0446766ec..91910ceed 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -55,6 +55,12 @@ const hoisted = vi.hoisted(() => { lastStopAt: null, lastError: null, }, + slack: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, signal: { running: false, lastStartAt: null, @@ -78,6 +84,8 @@ const hoisted = vi.hoisted(() => { stopTelegramProvider: vi.fn(async () => {}), startDiscordProvider: vi.fn(async () => {}), stopDiscordProvider: vi.fn(async () => {}), + startSlackProvider: vi.fn(async () => {}), + stopSlackProvider: vi.fn(async () => {}), startSignalProvider: vi.fn(async () => {}), stopSignalProvider: vi.fn(async () => {}), startIMessageProvider: vi.fn(async () => {}), diff --git a/src/gateway/server.ts b/src/gateway/server.ts index cbdde2fc6..ae3f76ae3 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -148,12 +148,14 @@ const logWsControl = log.child("ws"); const logWhatsApp = logProviders.child("whatsapp"); const logTelegram = logProviders.child("telegram"); const logDiscord = logProviders.child("discord"); +const logSlack = logProviders.child("slack"); const logSignal = logProviders.child("signal"); const logIMessage = logProviders.child("imessage"); const canvasRuntime = runtimeForLogger(logCanvas); const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp); const telegramRuntimeEnv = runtimeForLogger(logTelegram); const discordRuntimeEnv = runtimeForLogger(logDiscord); +const slackRuntimeEnv = runtimeForLogger(logSlack); const signalRuntimeEnv = runtimeForLogger(logSignal); const imessageRuntimeEnv = runtimeForLogger(logIMessage); @@ -478,6 +480,7 @@ export async function startGatewayServer( | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; to?: string; @@ -722,11 +725,13 @@ export async function startGatewayServer( logWhatsApp, logTelegram, logDiscord, + logSlack, logSignal, logIMessage, whatsappRuntimeEnv, telegramRuntimeEnv, discordRuntimeEnv, + slackRuntimeEnv, signalRuntimeEnv, imessageRuntimeEnv, }); @@ -736,11 +741,13 @@ export async function startGatewayServer( startWhatsAppProvider, startTelegramProvider, startDiscordProvider, + startSlackProvider, startSignalProvider, startIMessageProvider, stopWhatsAppProvider, stopTelegramProvider, stopDiscordProvider, + stopSlackProvider, stopSignalProvider, stopIMessageProvider, markWhatsAppLoggedOut, @@ -1593,7 +1600,7 @@ export async function startGatewayServer( } } - // Launch configured providers (WhatsApp Web, Discord, Telegram) so gateway replies via the + // Launch configured providers (WhatsApp Web, Discord, Slack, Telegram) so gateway replies via the // surface the message came from. Tests can opt out via CLAWDIS_SKIP_PROVIDERS. if (process.env.CLAWDIS_SKIP_PROVIDERS !== "1") { try { @@ -1703,6 +1710,9 @@ export async function startGatewayServer( startDiscordProvider, ); } + if (plan.restartProviders.has("slack")) { + await restartProvider("slack", stopSlackProvider, startSlackProvider); + } if (plan.restartProviders.has("signal")) { await restartProvider( "signal", @@ -1806,6 +1816,7 @@ export async function startGatewayServer( await stopWhatsAppProvider(); await stopTelegramProvider(); await stopDiscordProvider(); + await stopSlackProvider(); await stopSignalProvider(); await stopIMessageProvider(); await stopGmailWatcher(); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index d3e1ce383..3a18ffbbf 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -22,6 +22,7 @@ import { getQueueSize } from "../process/command-queue.js"; import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { sendMessageSignal } from "../signal/send.js"; +import { sendMessageSlack } from "../slack/send.js"; import { sendMessageTelegram } from "../telegram/send.js"; import { normalizeE164 } from "../utils.js"; import { getActiveWebListener } from "../web/active-listener.js"; @@ -38,12 +39,20 @@ export type HeartbeatTarget = | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage" | "none"; export type HeartbeatDeliveryTarget = { - channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage" | "none"; + channel: + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage" + | "none"; to?: string; reason?: string; }; @@ -53,6 +62,7 @@ type HeartbeatDeps = { sendWhatsApp?: typeof sendMessageWhatsApp; sendTelegram?: typeof sendMessageTelegram; sendDiscord?: typeof sendMessageDiscord; + sendSlack?: typeof sendMessageSlack; sendSignal?: typeof sendMessageSignal; sendIMessage?: typeof sendMessageIMessage; getQueueSize?: (lane?: string) => number; @@ -183,6 +193,7 @@ export function resolveHeartbeatDeliveryTarget(params: { rawTarget === "whatsapp" || rawTarget === "telegram" || rawTarget === "discord" || + rawTarget === "slack" || rawTarget === "signal" || rawTarget === "imessage" || rawTarget === "none" || @@ -209,6 +220,7 @@ export function resolveHeartbeatDeliveryTarget(params: { | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage" | undefined = @@ -217,6 +229,7 @@ export function resolveHeartbeatDeliveryTarget(params: { : target === "whatsapp" || target === "telegram" || target === "discord" || + target === "slack" || target === "signal" || target === "imessage" ? target @@ -288,7 +301,13 @@ function normalizeHeartbeatReply( } async function deliverHeartbeatReply(params: { - channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + channel: + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to: string; text: string; mediaUrls: string[]; @@ -299,6 +318,7 @@ async function deliverHeartbeatReply(params: { | "sendWhatsApp" | "sendTelegram" | "sendDiscord" + | "sendSlack" | "sendSignal" | "sendIMessage" > @@ -369,6 +389,20 @@ async function deliverHeartbeatReply(params: { return; } + if (channel === "slack") { + if (mediaUrls.length === 0) { + await deps.sendSlack(to, text); + return; + } + let first = true; + for (const url of mediaUrls) { + const caption = first ? text : ""; + first = false; + await deps.sendSlack(to, caption, { mediaUrl: url }); + } + return; + } + if (mediaUrls.length === 0) { await deps.sendDiscord(to, text, { verbose: false }); return; @@ -498,6 +532,7 @@ export async function runHeartbeatOnce(opts: { sendWhatsApp: opts.deps?.sendWhatsApp ?? sendMessageWhatsApp, sendTelegram: opts.deps?.sendTelegram ?? sendMessageTelegram, sendDiscord: opts.deps?.sendDiscord ?? sendMessageDiscord, + sendSlack: opts.deps?.sendSlack ?? sendMessageSlack, sendSignal: opts.deps?.sendSignal ?? sendMessageSignal, sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage, }; diff --git a/src/slack/actions.ts b/src/slack/actions.ts index 8763288b4..e53d94dbb 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -86,12 +86,11 @@ export async function listSlackReactions( export async function sendSlackMessage( to: string, content: string, - opts: SlackActionClientOpts & { mediaUrl?: string; replyTo?: string } = {}, + opts: SlackActionClientOpts & { mediaUrl?: string } = {}, ) { return await sendMessageSlack(to, content, { token: opts.token, mediaUrl: opts.mediaUrl, - threadTs: opts.replyTo, client: opts.client, }); } diff --git a/src/slack/index.ts b/src/slack/index.ts index af0c35741..817ef4ee2 100644 --- a/src/slack/index.ts +++ b/src/slack/index.ts @@ -12,4 +12,6 @@ export { unpinSlackMessage, } from "./actions.js"; export { monitorSlackProvider } from "./monitor.js"; +export { probeSlack } from "./probe.js"; export { sendMessageSlack } from "./send.js"; +export { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index dea86a8b1..b119931f4 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -6,7 +6,6 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { - ReplyToMode, SlackReactionNotificationMode, SlackSlashCommandConfig, } from "../config/config.js"; @@ -26,7 +25,6 @@ export type MonitorSlackOpts = { appToken?: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; - replyToMode?: ReplyToMode; mediaMaxMb?: number; slashCommand?: SlackSlashCommandConfig; }; @@ -135,18 +133,6 @@ type SlackChannelConfigResolved = { requireMention: boolean; }; -export function resolveSlackReplyTarget(opts: { - replyToMode: ReplyToMode; - replyToId?: string; - hasReplied: boolean; -}): string | undefined { - if (opts.replyToMode === "off") return undefined; - const replyToId = opts.replyToId?.trim(); - if (!replyToId) return undefined; - if (opts.replyToMode === "all") return replyToId; - return opts.hasReplied ? undefined : replyToId; -} - function normalizeSlackSlug(raw?: string) { const trimmed = raw?.trim().toLowerCase() ?? ""; if (!trimmed) return ""; @@ -353,7 +339,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels); const channelsConfig = cfg.slack?.channels; const dmEnabled = dmConfig?.enabled ?? true; - const replyToMode = opts.replyToMode ?? cfg.slack?.replyToMode ?? "off"; const reactionMode = cfg.slack?.reactionNotifications ?? "own"; const reactionAllowlist = cfg.slack?.reactionAllowlist ?? []; const slashCommand = resolveSlackSlashCommandConfig( @@ -583,6 +568,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const senderName = sender?.name ?? message.user; const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; + const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); + const inboundLabel = isDirectMessage + ? `Slack DM from ${senderName}` + : `Slack message in ${roomLabel} from ${senderName}`; + enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`, + }); + const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; const body = formatAgentEnvelope({ surface: "Slack", @@ -634,7 +627,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } if (shouldLogVerbose()) { - const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); logVerbose( `slack inbound: channel=${message.channel} from=${ctxPayload.From} preview="${preview}"`, ); @@ -656,7 +648,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { target: replyTarget, token: botToken, runtime, - replyToMode, textLimit, }); }) @@ -685,7 +676,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { target: replyTarget, token: botToken, runtime, - replyToMode, textLimit, }); if (shouldLogVerbose()) { @@ -1222,49 +1212,36 @@ async function deliverReplies(params: { target: string; token: string; runtime: RuntimeEnv; - replyToMode: ReplyToMode; textLimit: number; }) { const chunkLimit = Math.min(params.textLimit, 4000); - let hasReplied = false; for (const payload of params.replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; - const replyToId = payload.replyToId; if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { for (const chunk of chunkText(text, chunkLimit)) { - const threadTs = resolveSlackReplyTarget({ - replyToMode: params.replyToMode, - replyToId, - hasReplied, - }); + const threadTs = undefined; const trimmed = chunk.trim(); if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue; await sendMessageSlack(params.target, trimmed, { token: params.token, threadTs, }); - if (threadTs && !hasReplied) hasReplied = true; } } else { let first = true; for (const mediaUrl of mediaList) { const caption = first ? text : ""; first = false; - const threadTs = resolveSlackReplyTarget({ - replyToMode: params.replyToMode, - replyToId, - hasReplied, - }); + const threadTs = undefined; await sendMessageSlack(params.target, caption, { token: params.token, mediaUrl, threadTs, }); - if (threadTs && !hasReplied) hasReplied = true; } } params.runtime.log?.(`delivered reply to ${params.target}`); diff --git a/src/slack/probe.ts b/src/slack/probe.ts new file mode 100644 index 000000000..13f928d5a --- /dev/null +++ b/src/slack/probe.ts @@ -0,0 +1,59 @@ +import { WebClient } from "@slack/web-api"; + +export type SlackProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: { id?: string; name?: string }; + team?: { id?: string; name?: string }; +}; + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + if (!timeoutMs || timeoutMs <= 0) return promise; + let timer: NodeJS.Timeout | null = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +export async function probeSlack( + token: string, + timeoutMs = 2500, +): Promise { + const client = new WebClient(token); + const start = Date.now(); + try { + const result = await withTimeout(client.auth.test(), timeoutMs); + if (!result.ok) { + return { + ok: false, + status: 200, + error: result.error ?? "unknown", + elapsedMs: Date.now() - start, + }; + } + return { + ok: true, + status: 200, + elapsedMs: Date.now() - start, + bot: { id: result.user_id ?? undefined, name: result.user ?? undefined }, + team: { id: result.team_id ?? undefined, name: result.team ?? undefined }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = + typeof (err as { status?: number }).status === "number" + ? (err as { status?: number }).status + : null; + return { + ok: false, + status, + error: message, + elapsedMs: Date.now() - start, + }; + } +} diff --git a/src/slack/send.ts b/src/slack/send.ts new file mode 100644 index 000000000..3f1970f10 --- /dev/null +++ b/src/slack/send.ts @@ -0,0 +1,187 @@ +import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api"; + +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { loadConfig } from "../config/config.js"; +import { loadWebMedia } from "../web/media.js"; +import { resolveSlackBotToken } from "./token.js"; + +const SLACK_TEXT_LIMIT = 4000; + +type SlackRecipient = + | { + kind: "user"; + id: string; + } + | { + kind: "channel"; + id: string; + }; + +type SlackSendOpts = { + token?: string; + mediaUrl?: string; + client?: WebClient; + threadTs?: string; +}; + +export type SlackSendResult = { + messageId: string; + channelId: string; +}; + +function resolveToken(explicit?: string) { + const cfgToken = loadConfig().slack?.botToken; + const token = resolveSlackBotToken( + explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined, + ); + if (!token) { + throw new Error( + "SLACK_BOT_TOKEN or slack.botToken is required for Slack sends", + ); + } + return token; +} + +function parseRecipient(raw: string): SlackRecipient { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Recipient is required for Slack sends"); + } + const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i); + if (mentionMatch) { + return { kind: "user", id: mentionMatch[1] }; + } + if (trimmed.startsWith("user:")) { + return { kind: "user", id: trimmed.slice("user:".length) }; + } + if (trimmed.startsWith("channel:")) { + return { kind: "channel", id: trimmed.slice("channel:".length) }; + } + if (trimmed.startsWith("slack:")) { + return { kind: "user", id: trimmed.slice("slack:".length) }; + } + if (trimmed.startsWith("@")) { + const candidate = trimmed.slice(1); + if (!/^[A-Z0-9]+$/i.test(candidate)) { + throw new Error("Slack DMs require a user id (use user: or <@id>)"); + } + return { kind: "user", id: candidate }; + } + if (trimmed.startsWith("#")) { + const candidate = trimmed.slice(1); + if (!/^[A-Z0-9]+$/i.test(candidate)) { + throw new Error("Slack channels require a channel id (use channel:)"); + } + return { kind: "channel", id: candidate }; + } + return { kind: "channel", id: trimmed }; +} + +async function resolveChannelId( + client: WebClient, + recipient: SlackRecipient, +): Promise<{ channelId: string; isDm?: boolean }> { + if (recipient.kind === "channel") { + return { channelId: recipient.id }; + } + const response = await client.conversations.open({ users: recipient.id }); + const channelId = response.channel?.id; + if (!channelId) { + throw new Error("Failed to open Slack DM channel"); + } + return { channelId, isDm: true }; +} + +async function uploadSlackFile(params: { + client: WebClient; + channelId: string; + mediaUrl: string; + caption?: string; + threadTs?: string; + maxBytes?: number; +}): Promise { + const { buffer, contentType, fileName } = await loadWebMedia( + params.mediaUrl, + params.maxBytes, + ); + const basePayload = { + channel_id: params.channelId, + file: buffer, + filename: fileName, + ...(params.caption ? { initial_comment: params.caption } : {}), + ...(contentType ? { filetype: contentType } : {}), + }; + const payload: FilesUploadV2Arguments = params.threadTs + ? { ...basePayload, thread_ts: params.threadTs } + : basePayload; + const response = await params.client.files.uploadV2(payload); + const parsed = response as { + files?: Array<{ id?: string; name?: string }>; + file?: { id?: string; name?: string }; + }; + const fileId = + parsed.files?.[0]?.id ?? + parsed.file?.id ?? + parsed.files?.[0]?.name ?? + parsed.file?.name ?? + "unknown"; + return fileId; +} + +export async function sendMessageSlack( + to: string, + message: string, + opts: SlackSendOpts = {}, +): Promise { + const trimmedMessage = message?.trim() ?? ""; + if (!trimmedMessage && !opts.mediaUrl) { + throw new Error("Slack send requires text or media"); + } + const token = resolveToken(opts.token); + const client = opts.client ?? new WebClient(token); + const recipient = parseRecipient(to); + const { channelId } = await resolveChannelId(client, recipient); + const cfg = loadConfig(); + const textLimit = resolveTextChunkLimit(cfg, "slack"); + const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); + const chunks = chunkText(trimmedMessage, chunkLimit); + const mediaMaxBytes = + typeof cfg.slack?.mediaMaxMb === "number" + ? cfg.slack.mediaMaxMb * 1024 * 1024 + : undefined; + + let lastMessageId = ""; + if (opts.mediaUrl) { + const [firstChunk, ...rest] = chunks; + lastMessageId = await uploadSlackFile({ + client, + channelId, + mediaUrl: opts.mediaUrl, + caption: firstChunk, + threadTs: opts.threadTs, + maxBytes: mediaMaxBytes, + }); + for (const chunk of rest) { + const response = await client.chat.postMessage({ + channel: channelId, + text: chunk, + thread_ts: opts.threadTs, + }); + lastMessageId = response.ts ?? lastMessageId; + } + } else { + for (const chunk of chunks.length ? chunks : [""]) { + const response = await client.chat.postMessage({ + channel: channelId, + text: chunk, + thread_ts: opts.threadTs, + }); + lastMessageId = response.ts ?? lastMessageId; + } + } + + return { + messageId: lastMessageId || "unknown", + channelId, + }; +} diff --git a/src/slack/token.ts b/src/slack/token.ts new file mode 100644 index 000000000..2fbf215df --- /dev/null +++ b/src/slack/token.ts @@ -0,0 +1,12 @@ +export function normalizeSlackToken(raw?: string): string | undefined { + const trimmed = raw?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveSlackBotToken(raw?: string): string | undefined { + return normalizeSlackToken(raw); +} + +export function resolveSlackAppToken(raw?: string): string | undefined { + return normalizeSlackToken(raw); +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2d7f1eca3..0e3fb471c 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -27,6 +27,7 @@ import type { CronFormState, DiscordForm, IMessageForm, + SlackForm, SignalForm, TelegramForm, } from "./ui-types"; @@ -44,6 +45,7 @@ import { loadProviders, updateDiscordForm, updateIMessageForm, + updateSlackForm, updateSignalForm, updateTelegramForm, } from "./controllers/connections"; @@ -117,6 +119,11 @@ export type AppViewState = { discordSaving: boolean; discordTokenLocked: boolean; discordConfigStatus: string | null; + slackForm: SlackForm; + slackSaving: boolean; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackConfigStatus: string | null; signalForm: SignalForm; signalSaving: boolean; signalConfigStatus: string | null; @@ -269,6 +276,11 @@ export function renderApp(state: AppViewState) { discordTokenLocked: state.discordTokenLocked, discordSaving: state.discordSaving, discordStatus: state.discordConfigStatus, + slackForm: state.slackForm, + slackTokenLocked: state.slackTokenLocked, + slackAppTokenLocked: state.slackAppTokenLocked, + slackSaving: state.slackSaving, + slackStatus: state.slackConfigStatus, signalForm: state.signalForm, signalSaving: state.signalSaving, signalStatus: state.signalConfigStatus, @@ -283,6 +295,8 @@ export function renderApp(state: AppViewState) { onTelegramSave: () => state.handleTelegramSave(), onDiscordChange: (patch) => updateDiscordForm(state, patch), onDiscordSave: () => state.handleDiscordSave(), + onSlackChange: (patch) => updateSlackForm(state, patch), + onSlackSave: () => state.handleSlackSave(), onSignalChange: (patch) => updateSignalForm(state, patch), onSignalSave: () => state.handleSignalSave(), onIMessageChange: (patch) => updateIMessageForm(state, patch), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 3717edea9..d27a2a357 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -36,9 +36,11 @@ import type { } from "./types"; import { defaultDiscordActions, + defaultSlackActions, type CronFormState, type DiscordForm, type IMessageForm, + type SlackForm, type SignalForm, type TelegramForm, } from "./ui-types"; @@ -59,6 +61,7 @@ import { logoutWhatsApp, saveDiscordConfig, saveIMessageConfig, + saveSlackConfig, saveSignalConfig, saveTelegramConfig, startWhatsAppLogin, @@ -233,7 +236,6 @@ export class ClawdisApp extends LitElement { mediaMaxMb: "", historyLimit: "", textChunkLimit: "", - replyToMode: "off", guilds: [], actions: { ...defaultDiscordActions }, slashEnabled: false, @@ -244,6 +246,29 @@ export class ClawdisApp extends LitElement { @state() discordSaving = false; @state() discordTokenLocked = false; @state() discordConfigStatus: string | null = null; + @state() slackForm: SlackForm = { + enabled: true, + botToken: "", + appToken: "", + dmEnabled: true, + allowFrom: "", + groupEnabled: false, + groupChannels: "", + mediaMaxMb: "", + textChunkLimit: "", + reactionNotifications: "own", + reactionAllowlist: "", + slashEnabled: false, + slashName: "", + slashSessionPrefix: "", + slashEphemeral: true, + actions: { ...defaultSlackActions }, + channels: [], + }; + @state() slackSaving = false; + @state() slackTokenLocked = false; + @state() slackAppTokenLocked = false; + @state() slackConfigStatus: string | null = null; @state() signalForm: SignalForm = { enabled: true, account: "", @@ -774,6 +799,12 @@ export class ClawdisApp extends LitElement { await loadProviders(this, true); } + async handleSlackSave() { + await saveSlackConfig(this); + await loadConfig(this); + await loadProviders(this, true); + } + async handleSignalSave() { await saveSignalConfig(this); await loadConfig(this); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 184c5c709..afe4b88b5 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -6,11 +6,14 @@ import type { } from "../types"; import { defaultDiscordActions, + defaultSlackActions, type DiscordActionForm, type DiscordForm, type DiscordGuildChannelForm, type DiscordGuildForm, type IMessageForm, + type SlackChannelForm, + type SlackForm, type SignalForm, type TelegramForm, } from "../ui-types"; @@ -34,10 +37,12 @@ export type ConfigState = { lastError: string | null; telegramForm: TelegramForm; discordForm: DiscordForm; + slackForm: SlackForm; signalForm: SignalForm; imessageForm: IMessageForm; telegramConfigStatus: string | null; discordConfigStatus: string | null; + slackConfigStatus: string | null; signalConfigStatus: string | null; imessageConfigStatus: string | null; }; @@ -255,10 +260,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot typeof slack.textChunkLimit === "number" ? String(slack.textChunkLimit) : "", - replyToMode: - slack.replyToMode === "first" || slack.replyToMode === "all" - ? slack.replyToMode - : "off", reactionNotifications: slack.reactionNotifications === "off" || slack.reactionNotifications === "all" || @@ -492,4 +493,3 @@ function removePathValue( delete (current as Record)[lastKey]; } } - diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index 4f45dc192..76cc68065 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -3,11 +3,14 @@ import { parseList } from "../format"; import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types"; import { defaultDiscordActions, + defaultSlackActions, type DiscordActionForm, type DiscordForm, type DiscordGuildChannelForm, type DiscordGuildForm, type IMessageForm, + type SlackActionForm, + type SlackForm, type SignalForm, type TelegramForm, } from "../ui-types"; @@ -31,6 +34,11 @@ export type ConnectionsState = { discordSaving: boolean; discordTokenLocked: boolean; discordConfigStatus: string | null; + slackForm: SlackForm; + slackSaving: boolean; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackConfigStatus: string | null; signalForm: SignalForm; signalSaving: boolean; signalConfigStatus: string | null; @@ -54,6 +62,8 @@ export async function loadProviders(state: ConnectionsState, probe: boolean) { state.providersLastSuccess = Date.now(); state.telegramTokenLocked = res.telegram.tokenSource === "env"; state.discordTokenLocked = res.discord?.tokenSource === "env"; + state.slackTokenLocked = res.slack?.botTokenSource === "env"; + state.slackAppTokenLocked = res.slack?.appTokenSource === "env"; } catch (err) { state.providersError = String(err); } finally { @@ -136,6 +146,21 @@ export function updateDiscordForm( state.discordForm = { ...state.discordForm, ...patch }; } +export function updateSlackForm( + state: ConnectionsState, + patch: Partial, +) { + if (patch.actions) { + state.slackForm = { + ...state.slackForm, + ...patch, + actions: { ...state.slackForm.actions, ...patch.actions }, + }; + return; + } + state.slackForm = { ...state.slackForm, ...patch }; +} + export function updateSignalForm( state: ConnectionsState, patch: Partial, @@ -437,9 +462,6 @@ export async function saveSlackConfig(state: ConnectionsState) { delete slack.textChunkLimit; } - if (form.replyToMode === "off") delete slack.replyToMode; - else slack.replyToMode = form.replyToMode; - if (form.reactionNotifications === "own") { delete slack.reactionNotifications; } else { @@ -670,4 +692,3 @@ export async function saveIMessageConfig(state: ConnectionsState) { state.imessageSaving = false; } } - diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 0f1654658..eb1e2ce6f 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -3,6 +3,7 @@ export type ProvidersStatusSnapshot = { whatsapp: WhatsAppStatus; telegram: TelegramStatus; discord?: DiscordStatus | null; + slack?: SlackStatus | null; signal?: SignalStatus | null; imessage?: IMessageStatus | null; }; @@ -89,6 +90,37 @@ export type DiscordStatus = { lastProbeAt?: number | null; }; +export type SlackBot = { + id?: string | null; + name?: string | null; +}; + +export type SlackTeam = { + id?: string | null; + name?: string | null; +}; + +export type SlackProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: SlackBot | null; + team?: SlackTeam | null; +}; + +export type SlackStatus = { + configured: boolean; + botTokenSource?: string | null; + appTokenSource?: string | null; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: SlackProbe | null; + lastProbeAt?: number | null; +}; + export type SignalProbe = { ok: boolean; status?: number | null; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index b70019aa1..dd1a6a84c 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -84,7 +84,6 @@ export type SlackForm = { groupChannels: string; mediaMaxMb: string; textChunkLimit: string; - replyToMode: "off" | "first" | "all"; reactionNotifications: "off" | "own" | "all" | "allowlist"; reactionAllowlist: string; slashEnabled: boolean; @@ -168,4 +167,3 @@ export type CronFormState = { timeoutSeconds: string; postToMainPrefix: string; }; - diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index 22f3f9cd1..5212a1e92 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -6,6 +6,8 @@ import type { DiscordActionForm, DiscordForm, IMessageForm, + SlackActionForm, + SlackForm, SignalForm, TelegramForm, } from "../ui-types"; @@ -54,6 +56,11 @@ export type ConnectionsProps = { discordTokenLocked: boolean; discordSaving: boolean; discordStatus: string | null; + slackForm: SlackForm; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackSaving: boolean; + slackStatus: string | null; signalForm: SignalForm; signalSaving: boolean; signalStatus: string | null; @@ -68,6 +75,8 @@ export type ConnectionsProps = { onTelegramSave: () => void; onDiscordChange: (patch: Partial) => void; onDiscordSave: () => void; + onSlackChange: (patch: Partial) => void; + onSlackSave: () => void; onSignalChange: (patch: Partial) => void; onSignalSave: () => void; onIMessageChange: (patch: Partial) => void; @@ -78,12 +87,14 @@ export function renderConnections(props: ConnectionsProps) { const whatsapp = props.snapshot?.whatsapp; const telegram = props.snapshot?.telegram; const discord = props.snapshot?.discord ?? null; + const slack = props.snapshot?.slack ?? null; const signal = props.snapshot?.signal ?? null; const imessage = props.snapshot?.imessage ?? null; const providerOrder: ProviderKey[] = [ "whatsapp", "telegram", "discord", + "slack", "signal", "imessage", ]; @@ -101,7 +112,14 @@ export function renderConnections(props: ConnectionsProps) { return html`
${orderedProviders.map((provider) => - renderProvider(provider.key, props, { whatsapp, telegram, discord, signal, imessage }), + renderProvider(provider.key, props, { + whatsapp, + telegram, + discord, + slack, + signal, + imessage, + }), )}
@@ -135,7 +153,13 @@ function formatDuration(ms?: number | null) { return `${hr}h`; } -type ProviderKey = "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; +type ProviderKey = + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; function providerEnabled(key: ProviderKey, props: ConnectionsProps) { const snapshot = props.snapshot; @@ -151,6 +175,8 @@ function providerEnabled(key: ProviderKey, props: ConnectionsProps) { return snapshot.telegram.configured || snapshot.telegram.running; case "discord": return Boolean(snapshot.discord?.configured || snapshot.discord?.running); + case "slack": + return Boolean(snapshot.slack?.configured || snapshot.slack?.running); case "signal": return Boolean(snapshot.signal?.configured || snapshot.signal?.running); case "imessage": @@ -167,6 +193,7 @@ function renderProvider( whatsapp?: ProvidersStatusSnapshot["whatsapp"]; telegram?: ProvidersStatusSnapshot["telegram"]; discord?: ProvidersStatusSnapshot["discord"] | null; + slack?: ProvidersStatusSnapshot["slack"] | null; signal?: ProvidersStatusSnapshot["signal"] | null; imessage?: ProvidersStatusSnapshot["imessage"] | null; }, @@ -949,6 +976,389 @@ function renderProvider( `; } + case "slack": { + const slack = data.slack; + const botName = slack?.probe?.bot?.name; + const teamName = slack?.probe?.team?.name; + return html` +
+
Slack
+
Socket mode status and bot details.
+ +
+
+ Configured + ${slack?.configured ? "Yes" : "No"} +
+
+ Running + ${slack?.running ? "Yes" : "No"} +
+
+ Bot + ${botName ? botName : "n/a"} +
+
+ Team + ${teamName ? teamName : "n/a"} +
+
+ Last start + ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} +
+
+ Last probe + ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} +
+
+ + ${slack?.lastError + ? html`
+ ${slack.lastError} +
` + : nothing} + + ${slack?.probe + ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} · + ${slack.probe.status ?? ""} + ${slack.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + +
+ +
Slash command
+
+ + + + +
+ +
Channels
+
+ Add channel ids or #names and optionally require mentions. +
+
+ ${props.slackForm.channels.map( + (channel, channelIndex) => html` +
+
+
+ + + + +
+
+
+ `, + )} +
+ + +
Tool actions
+
+ ${slackActionOptions.map( + (action) => html``, + )} +
+ + ${props.slackTokenLocked || props.slackAppTokenLocked + ? html`
+ ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""} + ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} + is set in the environment. Config edits will not override it. +
` + : nothing} + + ${props.slackStatus + ? html`
+ ${props.slackStatus} +
` + : nothing} + +
+ + +
+
+ `; + } case "signal": { const signal = data.signal; return html` @@ -1355,4 +1765,3 @@ function renderProvider( return nothing; } } -