├── .gitignore ├── LICENSE ├── README.md ├── app.js ├── config.js ├── package.json ├── start.bat └── start.sh /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 PandarusAnon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Slaude is a small server serving as an interface between SillyTavern and Claude on Slack 2 | This aims to serve as an alternative, but not a replacement, for Spermack. If Spermack works fine for you or you like its features there is no reason to use this beyond curiosity. 3 | 4 | Credit to [Barbiariskaa/Spermack](https://github.com/Barbariskaa/Spermack) for the original idea to interface with Claude in Slack in this fashion. 5 | 6 | The main difference to Spermack is in the way the context is sent to Slack. Instead of sending messages directly to Claude as DMs, Slaude will instead create threads in the configured Slack channel. The entire workflow is: 7 | - Split the context into multiple parts that each fit into a single Slack message without cutoff 8 | - Send the first part to the configured channel 9 | - Send all other parts as replies to the original message 10 | - Ping Claude with a final message, triggering his response 11 | 12 | When working with threads Claude receives the entire message history of a thread as context. However, Claude will not actually respond to a thread until specifically asked to by pinging him. Claude has no knowledge of anything going on outside of the thread he's been called to with a ping. 13 | 14 | Editing your chat history in SillyTavern is still possible with this solution as each prompt creates a new thread. The amount of message spam won't really be any different than with Spermack, it'll just happen in a different channel and collapsed into threads. 15 | 16 | It does feel like sending the context this way has given Claude a bit more coherency but this might be entirely imagined. Feel free to try for yourself. 17 | 18 | # Setup guide 19 | ## Creating a Slack workspace 20 | First you will have to create a Slack workspace. You can do this for free at https://slack.com. Using an existing account and workspace is possible but very much not recommended. You'll also want to use open your workspace in a browser, not the Slack desktop app. 21 | 22 | Once your workspace is set up, add the [Claude app](https://slack.com/apps/A04KGS7N9A8-claude) to your workspace. You will also need a channel to send the prompt message to but any of the ones that a new Slack workspace comes with by default will do. 23 | 24 | ## Setting up Slaude 25 | Clone this repository or download it as a zip and extract it somewhere. You will also need Node and NPM installed but if you're using SillyTavern there is a good chance this is already the case so I'm not going to explain that here. 26 | 27 | Open the `config.js` file with a text editor of your choice. You will have to fill in these values with information from your Slack workspace. The following will be described assuming you are using Chrome as a browser, but this should be possible with all of them with some minor differences. 28 | 29 | ### TOKEN 30 | With your Slack workspace open in a browser, press F12 to open the developer tools and switch to the Network tab. With the developer tools still open, send a Slack message in any channel. You can use this opportunity to send any message to Claude which will prompt you to accept its ToS as that is required before using it. With that done, look for an entry starting with `chat.postMessage` in the Network tab, open it and click "Payload". In the Form Data you should find the token, starting with `xoxc-`. 31 | 32 | ### COOKIE 33 | Still in the developer tools, switch from the Network tab to the Application tab. On the left look for `Cookies -> https://app.slack.com` then copy the value starting with `xoxd-`. After this we are done with the developer tools. 34 | 35 | ### TEAM_ID 36 | This is the ID of your Slack workspace. You can find this by clicking on the name of Slack workspace in the top left. It should show your workspace URL. The part before `.slack.com` will be your TEAM_ID. For example, if the URL is `slaude-workspace.slack.com` then `slaude-workspace` is the TEAM_ID. 37 | 38 | ### CHANNEL 39 | The ID of the channel we want to start the prompt threads in. This can be any channel, but a good fit is the default `#random` channel that comes with your workspace by default. Whatever channel you go with, open it and click the little arrow at the top next to the channel name. You can find the channel ID at the bottom of the resulting popup. 40 | 41 | Once you've picked a channel and copied its ID, send a message anywhere in the channel that pings Claude with "@Claude". This will open a dialog asking for confirmation that Claude should be invited to the channel. Click on "Invite them" and Claude will now be able answer to your prompts in that channel. 42 | 43 | ### CLAUDE_USER 44 | Open your Claude DMs and similarly to the above step click on the little arrow next to Claude at the top. The _Member ID_ is what we're looking for here, not the Channel ID. 45 | 46 | ### PORT 47 | This only needs to be changed if you have anything else running on the same port already. 48 | 49 | ## Other settings 50 | The following settings aren't required for setup and can be left on their defaults. 51 | 52 | ### MAINPROMPT_LAST 53 | If set to true Slaude will try to move the main prompt, which is usually the block of text before the character definitions, to the bottom of the prompt we send to Slack. Can be helpful when as the context grows because this prevents the main prompt from being the first thing that falls out of memory. Both this setting and MAINPROMPT_AS_PING will break if you use double linebreaks anywhere in your main prompt or NSFW prompt as that's the only real way we have to determine where the main prompt stops and the character definition starts. That's a bit flimsier than I'd like but I'll look into other options. Single linebreaks are fine. 54 | 55 | ### MAINPROMPT_AS_PING 56 | If set to true does the same as above, but uses the contents of the main prompt as the PING_MESSAGE instead (see below). This completely replaces the PING_MESSAGE set in the config. Using the main prompt we get from SillyTavern has the advantage that we have access to all placeholders, like `{{char}}` and `{{user}}`, and can use presets to change the PING_MESSAGE instead of having to edit the config every time. If you put an @Claude anywhere in your main prompt or NSFW prompt it will get replaced with the actual Claude ping, same as PING_MESSAGE. The jailbreak from SillyTavern is not included in this as it gets placed apart from the rest of the main prompt. If you want your jailbreak to be in the ping message too, try putting it in the main prompt or NSFW prompt instead. 57 | 58 | ### USE_BLOCKS 59 | If set to false Slaude will send the prompt messages as plain text instead of Slack's blocks format. The result on Slack's end will be the same, though it reduces the maximum size of a single message to 4000 characters. This might not actually make any difference but turning this off might be slightly less error prone because we can just send the text as is instead of having to wrap it in blocks JSON. 60 | 61 | ### STREAMING_TIMEOUT 62 | Sets the timeout in milliseconds for streaming the response back to SillyTavern if streaming is enabled. Default is 240000ms = 4m. After this time the stream will be force closed so SillyTavern doesn't hang forever for a response that isn't coming. Can be set to 0 to disable the timeout entirely though that is not recommended. 63 | 64 | ### PING_MESSAGE 65 | This lets you configure what gets sent in the message that we use to ping Claude. This seems to have a pretty huge influence on his response, similar to a jailbreak. The default message is `"Assistant:"` and will prompt Claude to continue the context we sent him to the best of his abilites. If you put "@Claude" anywhere in the string that's where Slaude will put the actual ping, otherwise it will automatically be in front of the message. I would recommend to use the MAINPROMPT_AS_PING setting above instead but you can also use MAINPROMPT_LAST together with PING_MESSAGE if you want to have control over both. 66 | 67 | ## Starting the server and connecting to SillyTavern 68 | It is recommended but not required to use the latest dev version of SillyTavern. 69 | 70 | Run start.bat or start.sh depending on your system of choice or just do `npm install` and `node app.js` manually. It's not rocket science. Once the server is running you should see `Slaude is running at http://localhost:5004` if you're using the default port. 71 | 72 | In SillyTavern, click the API connections button and switch the API to OpenAI. Enter whatever you want in the API key field. The selected model doesn't matter either. 73 | 74 | In the AI Response Configuration, paste the above URL into the Reverse Proxy field. I recommend creating a new preset for Claude. If you already have one for Spermack that should work fine. Set the context size to something sensible. It's hard to tell how much context Slack actually lets us use so it's difficult to make a definite recommendation. Smaller context sizes will result in less dementia over time but also less memory. You shouldn't have to go lower than 3.5k though. Max Response Length, Temperature, Frequency Penalty and Presence Penalty are all irrelevant and will be ignored, as will most other OpenAI specific settings. Streaming should work but I personally don't use it so I didn't test it that much. 75 | 76 | What to use for your prompts is up to personal preference. I personally don't believe Claude needs any jailbreaks and never used any. If you do decide to send the jailbreak _do not use the default SillyTavern jailbreak_. 77 | 78 | Once all that is set up press "Connect" back in API Connections. If everything was set up correctly the dot under the button should go green and say "Valid". 79 | 80 | If you configured everything in Slaude correctly as well and made sure to accept Claude's ToS you should now be good to start prompting. 81 | 82 | Note that you do not need to have Slack open in your browser for this to work. Once this is set up you can in theory just never open your workspace again. 83 | 84 | ## Untested guide for running Slaude on Android with termux 85 | Not tested if this works myself, but this guide was provided by a nice anon. This guide assumes you've already set up SillyTavern with termux. 86 | 87 | Copy your config.js file from your desktop to your phone if you already have it set up there. Make sure you're using the same fork. If you don't have it setup then: Download the https://github.com/PandarusAnon/slaude zip off of GitHub using your phone's browser. (Use desktop mode code download zip). 88 | Unzip the contents, open config.js file in a text editor. Edit the contents of the file following the instructions on the git page and save. 89 | Use kiwi browser for access to developer tools. It's a hassle on mobile, just use desktop this one time copying the cookies and shit. 90 | If you don't have a text editor (wow) use Acode or FX explorer. 91 | 92 | termux-storage-get requires the Termux:API addon. https://wiki.termux.com/wiki/Termux-storage-get 93 | Get it from here: https://www.f-droid.org/en/packages/com.termux.api/ and then run pkg install termux-api -y in termux. 94 | If you don't want to install an extra app, you can learn to use a terminal text editor (look up how to use nano or vim) or follow the guide here: https://wiki.termux.com/wiki/Internal_and_external_storage to connect your termux storage to FX Explorer and edit files from there. 95 | 96 | Now open termux and run the following: 97 | `termux-setup-storage` click allow storage access. 98 | `git clone https://github.com/PandarusAnon/slaude slaude` use the git link for whatever fork you want to use. 99 | `cd slaude` 100 | `npm install` 101 | `termux-storage-get config.js` Browse to the directory where you have your edited config.js file, and select the file to import it. 102 | Now you have installed and setup all the files necessary. To run Slaude do: `node app.js`. This step may be called something else depending on which git you selected so refer to the git page for the command to run the server. 103 | Now to run Silly, you need to run a separate Termux session. You can do that by pulling the sidebar from the top left. Tap and pull from the top left corner. Look up how to pull sidebars with gesture navigation on youtube if you're having trouble. 104 | Click on new session. You're loaded into a new session now, in the slaude directory though so you'll have to change to the silly directory. Do: `cd ~/silly_directory_name`. Now run your silly server with the regular `node server.js`. Follow the API instructions on the git page, and voila! 105 | 106 | To run Slaude + Tavern next time, open termux like usual. In the first session do: `cd slaude && node app.js` to run slaude. Switch to a new session and `cd ~/silly_directory && node server.js`. 107 | \>I can't open the sidebar! 108 | Do: `nano ~/.termux/termux.properties` 109 | Type the line: `shortcut.create-session=ctrl + t` anywhere in the file, just make sure you're writing on a fresh line and press ctrl+o, enter, ctrl+x. 110 | Now to run a new session, simply press ctrl+t. 111 | 112 | 113 | # Final note 114 | I don't believe in names or avatars. I will be PandarusAnon on here because I needed a name for GitHub but I don't and won't use this name anywhere else. -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import axios from 'axios'; 3 | import FormData from 'form-data'; 4 | import WebSocket from 'ws'; 5 | import config from './config.js'; 6 | 7 | const app = express(); 8 | 9 | const rename_roles = { 10 | 'user': 'Human', 11 | 'assistant': 'Assistant', 12 | 'example_user': 'H', 13 | 'example_assistant': 'A' 14 | } 15 | 16 | const typingString = "\n\n_Typing…_"; 17 | 18 | const maxMessageLength = config.USE_BLOCKS ? 12000 : 4000; 19 | 20 | var lastMessage = ''; 21 | 22 | var streamQueue = Promise.resolve(); 23 | 24 | app.use(express.json()); 25 | 26 | /** SillyTavern calls this to check if the API is available, the response doesn't really matter */ 27 | app.get('/(.*)/models', (req, res) => { 28 | res.json({ 29 | object: 'list', 30 | data: [{id: 'claude-v1', object: 'model', created: Date.now(), owned_by: 'anthropic', permission: [], root: 'claude-v1', parent: null}] 31 | }); 32 | }); 33 | 34 | /** 35 | * SillyTavern calls this endpoint for prompt completion, if streaming is enabled it will stream Claude's response back to SillyTavern 36 | * as it is being typed on Slack, otherwise it will just wait until Claude stops typing and then return the entire message at once as an OpenAI completion result 37 | * Does the following: 38 | * - Build the prompt messages from the request data 39 | * - Post a new message with the first prompt chunk in the configured Slack channel, save the Slack timestamp of the created message 40 | * - Post one message as reply to the first message for each prompt chunk, creating a thread from the first message 41 | * - Once all parts of the prompt are sent, open WebSocket connection and register event handlers to start listening for Claude's response 42 | * - Send one final message to the thread that pings Claude, causing him to start generating a response using all messages currently in the thread as context 43 | * After that the WS event handlers will wait for Claude to finish responding then write his message back into the Response for SillyTavern 44 | */ 45 | app.post('/(.*)/chat/completions', async (req, res, next) => { 46 | if (!('messages' in req.body)) { 47 | throw new Error('Completion request not in expected format, make sure SillyTavern is set to use OpenAI.'); 48 | } 49 | 50 | try { 51 | let stream = req.body.stream ?? false; 52 | let promptMessages = buildSlackPromptMessages(req.body.messages); 53 | 54 | let tsThread = await createSlackThread(promptMessages[0]); 55 | 56 | if (tsThread === null || tsThread === undefined) { 57 | throw new Error("First message did not return a thread timestamp. Make sure that CHANNEL is set to a channel ID that both your Slack user and Claude have access to.") 58 | } 59 | 60 | console.log(`Created thread with ts ${tsThread}`); 61 | 62 | let pingMessage = config.PING_MESSAGE; 63 | if (config.MAINPROMPT_AS_PING) { 64 | pingMessage = promptMessages.pop(); 65 | } 66 | 67 | if (promptMessages.length > 1) { 68 | for (let i = 1; i < promptMessages.length; i++) { 69 | await createSlackReply(promptMessages[i], tsThread); 70 | console.log(`Created ${i}. reply on thread ${tsThread}`); 71 | } 72 | } 73 | 74 | let ws = await openWebSocketConnection(); 75 | let timeout = null; 76 | 77 | if (stream) { 78 | res.setHeader("Content-Type", "text/event-stream"); 79 | console.log("Opened stream for Claude's response."); 80 | streamQueue = Promise.resolve(); 81 | 82 | // every request need a empty lastMessage 83 | lastMessage = ''; 84 | ws.on("message", (message) => { 85 | streamQueue = streamQueue.then(streamNextClaudeResponseChunk.bind(this, message, res)); 86 | }); 87 | 88 | if (config.STREAMING_TIMEOUT > 0) { 89 | timeout = setTimeout(() => { 90 | console.log("Streaming response taking too long, closing stream.") 91 | finishStream(res); 92 | }, config.STREAMING_TIMEOUT); 93 | } 94 | } else { 95 | console.log("Awaiting Claude's response."); 96 | ws.on("message", (message) => { 97 | getClaudeResponse(message, res); 98 | }); 99 | } 100 | 101 | res.on("finish", () => { 102 | ws.close(); 103 | console.log("Finished returning Claude's response."); 104 | if (timeout) { 105 | clearTimeout(timeout); 106 | } 107 | }); 108 | 109 | await createClaudePing(pingMessage, tsThread); 110 | console.log(`Created Claude ping on thread ${tsThread}`); 111 | } catch (error) { 112 | console.error(error); 113 | next(error); 114 | } 115 | }); 116 | 117 | app.listen(config.PORT, () => { 118 | console.log(`Slaude is running at http://localhost:${config.PORT}`); 119 | checkConfig(); 120 | }); 121 | 122 | function checkConfig() { 123 | if (config.TOKEN.length <= 9 || !config.TOKEN.startsWith("xoxc")) { 124 | console.warn("TOKEN looks abnormal, please verify TOKEN setting"); 125 | } 126 | if (config.COOKIE.length <= 9 || !config.COOKIE.startsWith("xoxd")) { 127 | console.warn("COOKIE looks abnormal, please verify COOKIE setting"); 128 | } 129 | if (config.TEAM_ID.includes('.slack.com')) { 130 | console.warn("TEAM_ID needs to be the part before '.slack.com', not the entire URL."); 131 | } 132 | if (!config.CHANNEL.startsWith('C')) { 133 | console.warn("Your CHANNEL might be wrong, please make sure you copy the channel ID of a channel you and Claude both have access to, like #random."); 134 | } 135 | if (config.CHANNEL.startsWith('D')) { 136 | console.warn("It looks like you might have put Claude's DM channel ID into the CHANNEL setting."); 137 | } 138 | if (!config.CLAUDE_USER.startsWith('U')) { 139 | console.warn("Your CLAUDE_USER might be wrong, please make sure you copied Claude's Member ID, NOT his Channel ID"); 140 | } 141 | if (config.CLAUDE_USER.startsWith('D')) { 142 | console.warn("It looks like you might have put Claude's DM channel ID into the CLAUDE_USER setting, plase make sure you use his Member ID instead."); 143 | } 144 | if (config.PING_MESSAGE.length === 0) { 145 | console.warn('PING_MESSAGE should not be completely empty, otherwise Claude will not produce a response. If you want nothing in the ping message except for the Claude ping, make sure there is at least an empty space in the string, like " "'); 146 | } 147 | } 148 | 149 | /** Opens a WebSocket connection to Slack with an awaitable Promise */ 150 | function openWebSocketConnection() { 151 | return new Promise((resolve, reject) => { 152 | setTimeout(() => { 153 | reject('Timed out establishing WebSocket connection.'); 154 | }, 30000); 155 | 156 | var ws = new WebSocket(`wss://wss-primary.slack.com/?token=${config.TOKEN}`, { 157 | headers: { 158 | 'Cookie': `d=${config.COOKIE};`, 159 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/112.0' 160 | } 161 | }); 162 | 163 | ws.on("open", () => { 164 | resolve(ws); 165 | }) 166 | 167 | ws.on("close", (code, reason) => { 168 | if (code !== 1000 && code !== 1005) { 169 | console.error(`WebSocket connection closed abnormally with code ${code}. Your cookie and/or token might be incorrect or expired.`) 170 | } 171 | }) 172 | }); 173 | } 174 | 175 | /** 176 | * Hacky bullshit that compares the last message we got from Slack with the current one and returns the difference. 177 | * Only needed for streaming. 178 | */ 179 | function getNextChunk(text) { 180 | // Current and last message are identical, can skip streaming a chunk. 181 | if (text === lastMessage) { 182 | return ''; 183 | } 184 | 185 | // if the next message doesn't have the entire previous message in it we received something out of order and dismissing it is the safest option 186 | if (!text.includes(lastMessage)) { 187 | return ''; 188 | } 189 | 190 | let chunk = text.slice(lastMessage.length, text.length); 191 | lastMessage = text; 192 | return chunk; 193 | } 194 | 195 | /** Strips the "Typing..." string from the end of Claude's messages. */ 196 | function stripTyping(text) { 197 | return text.slice(0, text.length-typingString.length); 198 | } 199 | 200 | /** 201 | * Used as a callback for WebSocket to get the next chunk of the response Claude is currently typing and 202 | * write it into the response for SillyTavern. Used for streaming. 203 | * @param {*} message The WebSocket message object 204 | * @param {*} res The Response object for SillyTavern's request 205 | */ 206 | function streamNextClaudeResponseChunk(message, res) { 207 | return new Promise((resolve, reject) => { 208 | try { 209 | let data = JSON.parse(message); 210 | if (data.subtype === 'message_changed') { 211 | let text = data.message.text; 212 | let stillTyping = text.endsWith(typingString); 213 | text = stillTyping ? stripTyping(text) : text; 214 | let chunk = getNextChunk(text); 215 | 216 | if (chunk.length === 0) { 217 | resolve(); 218 | return; 219 | } 220 | 221 | let streamData = { 222 | choices: [{ 223 | delta: { 224 | content: chunk 225 | } 226 | }] 227 | }; 228 | 229 | res.write('\n\ndata: ' + JSON.stringify(streamData)); 230 | 231 | if (!stillTyping) { 232 | finishStream(res); 233 | } 234 | } 235 | resolve(); 236 | } catch (error) { 237 | console.error('Error parsing Slack WebSocket message'); 238 | reject(error); 239 | } 240 | }); 241 | } 242 | 243 | /** 244 | * Used as a callback for WebSocket to get Claude's response. Won't actually do anything until Claude stops "typing" 245 | * and then send it back to SillyTavern as an OpenAI chat completion result. Used when not streaming. 246 | * @param {*} message The WebSocket message object 247 | * @param {*} res The Response object for SillyTavern's request 248 | */ 249 | function getClaudeResponse(message, res) { 250 | try { 251 | let data = JSON.parse(message); 252 | if (data.subtype === 'message_changed') { 253 | if (!data.message.text.endsWith(typingString)) { 254 | res.json({ 255 | choices: [{ 256 | message: { 257 | content: data.message.text 258 | } 259 | }] 260 | }); 261 | } else { 262 | // mostly just leaving this log in since there is otherwise zero feedback that something is incoming from Slack 263 | console.log(`received ${data.message.text.length} characters...`); 264 | } 265 | } 266 | } catch (error) { 267 | console.error('Error parsing Slack WebSocket message:', error); 268 | } 269 | } 270 | 271 | /** 272 | * Simply sends [DONE] on the event stream to let SillyTavern know nothing else is coming. 273 | * Used both to finish the response when we're done, as well as on errors so the stream still closes neatly 274 | * @param {*} res - The Response object for SillyTavern's request 275 | */ 276 | function finishStream(res) { 277 | lastMessage = ''; 278 | res.write('\n\ndata: [DONE]'); 279 | res.end(); 280 | } 281 | 282 | /** 283 | * Takes the OpenAI formatted messages send by SillyTavern and converts them into multiple plain text 284 | * prompt chunks. Each chunk should fit into a single Slack chat message without getting cut off. 285 | * Default is 12000 characters. Slack messages can fit a little more but that gives us some leeway. 286 | * @param {*} messages Prompt messages in OpenAI chat completion format 287 | * @returns An array of plain text prompt chunks 288 | */ 289 | function buildSlackPromptMessages(messages) { 290 | let prompts = []; 291 | let currentPrompt = ''; 292 | let mainPrompt = ''; 293 | if (config.MAINPROMPT_LAST || config.MAINPROMPT_AS_PING) { 294 | let firstMessage = convertToPrompt(messages[0]); 295 | let index = firstMessage.indexOf('\n\n'); 296 | 297 | if (index > 0) { 298 | mainPrompt = firstMessage.slice(0, index); 299 | currentPrompt = firstMessage.slice(index, firstMessage.length); 300 | if (currentPrompt.length > maxMessageLength) { 301 | currentPrompt = splitPrompt(currentPrompt, prompts); 302 | } 303 | messages.shift(); 304 | } else { 305 | console.warn("Unable to determine cutoff point for main prompt, reverting to default behavior."); 306 | config.MAINPROMPT_LAST = false; 307 | config.MAINPROMPT_AS_PING = false; 308 | } 309 | } 310 | 311 | for (let i = 0; i < messages.length; i++) { 312 | let msg = messages[i]; 313 | let promptPart = convertToPrompt(msg); 314 | if (currentPrompt.length + promptPart.length < maxMessageLength) { 315 | currentPrompt += promptPart; 316 | } else { 317 | prompts.push(currentPrompt); 318 | currentPrompt = promptPart; 319 | if (currentPrompt.length > maxMessageLength) { 320 | currentPrompt = splitPrompt(currentPrompt, prompts); 321 | } 322 | } 323 | } 324 | prompts.push(currentPrompt); 325 | 326 | if (config.MAINPROMPT_LAST || config.MAINPROMPT_AS_PING) { 327 | prompts.push(mainPrompt); 328 | } 329 | 330 | return prompts; 331 | } 332 | 333 | function splitPrompt(text, prompts) { 334 | let whiteSpaceMatch = text.slice(0, maxMessageLength).match(/\s(?=[^\s]*$)/); 335 | let splitIndex = whiteSpaceMatch === null ? maxMessageLength : whiteSpaceMatch.index + 1; 336 | prompts.push(text.slice(0, splitIndex)); 337 | let secondHalf = text.slice(splitIndex, text.length); 338 | if (secondHalf > maxMessageLength) { 339 | return splitPrompt(secondHalf, prompts); 340 | } 341 | return secondHalf; 342 | } 343 | 344 | /** 345 | * Takes an OpenAI message and translates it into a format of "Role: Message" 346 | * Messages from the System role are send as is. 347 | * For example dialogue it takes the actual role from the 'name' property instead. 348 | * By default the role "user" is replaced with "Human" and the role "assistant" with "Assitant" 349 | * @param {*} msg 350 | * @returns 351 | */ 352 | function convertToPrompt(msg) { 353 | if (msg.role === 'system') { 354 | if ('name' in msg) { 355 | return `${rename_roles[msg.name]}: ${msg.content}\n\n` 356 | } 357 | else { 358 | return `${msg.content}\n\n` 359 | } 360 | } 361 | else { 362 | return `${rename_roles[msg.role]}: ${msg.content}\n\n` 363 | } 364 | } 365 | 366 | function preparePingMessage(msg) { 367 | const claudePing = `<@${config.CLAUDE_USER}>`; 368 | let claudePingMatch = msg.match(/@Claude/i); 369 | if (claudePingMatch === null) { 370 | return `${claudePing} ${msg}`; 371 | } 372 | 373 | return msg.replace(claudePingMatch[0], claudePing); 374 | } 375 | 376 | /** 377 | * Posts a chat message to Slack, depending on the parameters 378 | * @param {*} msg The message text, if applicable 379 | * @param {*} thread_ts The Slack timestamp of the message we want to reply to 380 | * @param {*} pingClaude Whether to ping Claude with the message 381 | * @returns 382 | */ 383 | async function postSlackMessage(msg, thread_ts, pingClaude) { 384 | var form = new FormData(); 385 | form.append('token', config.TOKEN); 386 | form.append('channel', `${config.CHANNEL}`); 387 | form.append('_x_mode', 'online'); 388 | form.append('_x_sonic', 'true'); 389 | form.append('type', 'message'); 390 | form.append('xArgs', '{}'); 391 | form.append('unfurl', '[]'); 392 | form.append('include_channel_perm_error', 'true'); 393 | form.append('_x_reason', 'webapp_message_send'); 394 | 395 | if (thread_ts !== null) { 396 | form.append('thread_ts', thread_ts); 397 | } 398 | 399 | if (pingClaude) { 400 | msg = preparePingMessage(msg); 401 | } 402 | 403 | if (config.USE_BLOCKS) { 404 | let blocks = [{ 405 | 'type': 'rich_text', 406 | 'elements': [{ 407 | 'type': 'rich_text_section', 408 | 'elements': [{ 409 | 'type': 'text', 410 | 'text': msg 411 | }] 412 | }] 413 | }]; 414 | form.append('blocks', JSON.stringify(blocks)); 415 | } else { 416 | form.append('text', msg); 417 | } 418 | 419 | var res = await axios.post(`https://${config.TEAM_ID}.slack.com/api/chat.postMessage`, form, { 420 | headers: { 421 | 'Cookie': `d=${config.COOKIE};`, 422 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/112.0', 423 | ...form.getHeaders() 424 | } 425 | }); 426 | 427 | if ("ok" in res.data && !res.data.ok) { 428 | if ("error" in res.data) { 429 | if (res.data.error === "invalid_auth" || res.data.error === "not_authed") { 430 | throw new Error("Failed posting message to Slack. Your TOKEN and/or COOKIE might be incorrect or expired."); 431 | } else { 432 | throw new Error(res.data.error); 433 | } 434 | } else { 435 | throw new Error(res.data); 436 | } 437 | } 438 | 439 | return res.data.ts; 440 | } 441 | 442 | async function createSlackThread(promptMsg) { 443 | return await postSlackMessage(promptMsg, null, false); 444 | } 445 | 446 | async function createSlackReply(promptMsg, ts) { 447 | return await postSlackMessage(promptMsg, ts, false); 448 | } 449 | 450 | async function createClaudePing(promptMsg, ts) { 451 | return await postSlackMessage(promptMsg, ts, true); 452 | } 453 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | TOKEN: "xoxc-xxxx", 3 | COOKIE: "xoxd-xxxx", 4 | TEAM_ID: "workspacename", 5 | CHANNEL: "C1234D4567S", 6 | CLAUDE_USER: "U1234A568BC", 7 | 8 | MAINPROMPT_LAST: false, // Will try to move the main prompt (main + nsfw prompt from SillyTavern) to the bottom, similar to a jailbreak 9 | MAINPROMPT_AS_PING: false, // Will use the main prompt as the ping message, if this true the PING_MESSAGE setting is ignored. Overrides MAINPROMPT_LAST. 10 | 11 | // If false will make Slaude send messages as plain text rather than through Slack's blocks format. The results in Slack should be the same either way. 12 | USE_BLOCKS: true, 13 | 14 | STREAMING_TIMEOUT: 240000, //Time in milliseconds after which the response stream will force close itself, 0 disables the timeout but is not recommended. 15 | 16 | // The final message we send with an @Claude ping to trigger his response. You can add "@Claude" to this string to control where the ping will be inside the message. 17 | // If there is no @Claude, the ping will automatically get added in front of the message. 18 | // Can be whatever you want it to be but keep in mind that it might result in unpredictable responses. 19 | // Anything we put here will eat into our max available tokens so keep it brief. 20 | PING_MESSAGE: "Assistant:", 21 | 22 | PORT: 5004 23 | } 24 | 25 | export default config; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slaude", 3 | "version": "1.0.0", 4 | "description": "Interface between SillyTavern and Claude on Slack", 5 | "type": "module", 6 | "exports": "./app.js", 7 | "scripts": { 8 | "start": "node ./app.js" 9 | }, 10 | "author": "anon", 11 | "license": "MIT", 12 | "dependencies": { 13 | "axios": "^1.4.0", 14 | "express": "^5.0.0-beta.1", 15 | "form-data": "^4.0.0", 16 | "ws": "^8.13.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /start.bat: -------------------------------------------------------------------------------- 1 | call npm install 2 | node app.js 3 | pause -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npm install 4 | node app.js --------------------------------------------------------------------------------