├── .gitignore ├── branding ├── logo-64x64.png ├── logo-full.PNG └── logo-128x128.png ├── .env.example ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── improvement.yml │ └── bug-report.yml ├── dependabot.yml ├── FUNDING.yml └── workflows │ └── build.yml ├── docker-compose.yml ├── Dockerfile ├── tsconfig.json ├── src ├── types │ ├── needleCommand.ts │ ├── messageContext.ts │ └── needleConfig.ts ├── handlers │ ├── interactionHandler.ts │ ├── commandHandler.ts │ └── messageHandler.ts ├── helpers │ ├── threadHelpers.ts │ ├── permissionHelpers.ts │ ├── messageHelpers.ts │ └── configHelpers.ts ├── config.json ├── index.ts └── commands │ ├── title.ts │ ├── close.ts │ ├── help.ts │ └── configure.ts ├── package.json ├── .eslintrc.json ├── PRIVACY_POLICY.md ├── README.md ├── scripts └── deploy-commands.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | configs/ 4 | 5 | .env 6 | -------------------------------------------------------------------------------- /branding/logo-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vaibhavs10/discord-needle/main/branding/logo-64x64.png -------------------------------------------------------------------------------- /branding/logo-full.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vaibhavs10/discord-needle/main/branding/logo-full.PNG -------------------------------------------------------------------------------- /branding/logo-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vaibhavs10/discord-needle/main/branding/logo-128x128.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_API_TOKEN= 2 | CLIENT_ID= 3 | GUILD_ID= 4 | # This is relative to Needle's root directory, and defaults to "configs" 5 | CONFIGS_PATH= -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | dist/ 4 | .github/ 5 | configs/ 6 | branding/ 7 | scripts/ 8 | PRIVACY_POLICY.md 9 | README.md 10 | docker-compose.yml 11 | .gitignore 12 | .eslintrc.json 13 | .env.example 14 | .env -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🐇 Discord Chat 4 | url: https://needle.gg/chat 5 | about: Join the Discord server to get answers to your questions ASAP 6 | - name: 🐌 GitHub Discussions 7 | url: https://github.com/MarcusOtter/discord-needle/discussions 8 | about: If you prefer, you can also open a discussion here on GitHub with questions 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | needle: 3 | image: ghcr.io/MarcusOtter/discord-needle:2.0.0 4 | restart: unless-stopped 5 | environment: 6 | - DISCORD_API_TOKEN= 7 | 8 | # OPTIONAL: Pass in a .env file rather than specifying it here 9 | # env_file: 10 | # - .env 11 | 12 | # OPTIONAL: Use a named volume instead of an anonymous one 13 | # volumes: 14 | # - /some/configs/directory:/configs -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM node:16.14.0-slim AS build 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | RUN npm install 7 | 8 | COPY . . 9 | RUN npm run build 10 | 11 | # Run 12 | FROM node:16.14.0-slim 13 | WORKDIR /app 14 | 15 | ENV CONFIGS_PATH=/configs 16 | ENV NODE_ENV=production 17 | 18 | COPY package*.json ./ 19 | RUN npm install --production 20 | COPY --from=build /app/dist ./dist 21 | 22 | VOLUME [ "/configs" ] 23 | 24 | USER node 25 | 26 | CMD ["node", "--enable-source-maps", "./dist/index.js"] -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "strict": true, 5 | "skipLibCheck": true, 6 | "strictNullChecks": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "target": "ES2021", 9 | "lib": ["ES2021"], 10 | "allowUnreachableCode": false, 11 | "allowUnusedLabels": false, 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "rootDir": "src", 15 | "outDir": "dist", 16 | "sourceMap": true, 17 | }, 18 | "include": ["src/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: MarcusOtter # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/improvement.yml: -------------------------------------------------------------------------------- 1 | name: "💡 Improvement" 2 | description: Suggest an improvement for Needle 3 | title: "💡 " 4 | labels: ["improvement 💡"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Describe the improvement 10 | description: What improvement do you want added to Needle? Use as much detail as possible. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: fixes 15 | attributes: 16 | label: Problems this improvement solves 17 | description: Is this improvement related to a problem? How does it solve that? 18 | - type: textarea 19 | id: alternatives 20 | attributes: 21 | label: Alternative solutions 22 | description: Describe any alternative solutions or features you've considered. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug report" 2 | description: File a bug report 3 | title: "🐛 " 4 | labels: ["potential bug 🐛"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Describe the bug 10 | description: Describe the issue in as much detail as possible. Provide screenshots & recordings of the issue if possible. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: reproduce 15 | attributes: 16 | label: Steps to reproduce the bug 17 | placeholder: | 18 | 1. Run command 19 | 2. Click on thing 20 | 3. Do something 21 | 4. Observe 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: expected 26 | attributes: 27 | label: Expected behavior 28 | validations: 29 | required: true 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Docker meta 17 | id: meta 18 | uses: docker/metadata-action@v3 19 | with: 20 | images: ghcr.io/${{ github.repository }} 21 | tags: | 22 | type=ref,event=branch 23 | type=ref,event=pr 24 | type=semver,pattern={{version}} 25 | type=semver,pattern={{major}}.{{minor}} 26 | 27 | - name: Login to GitHub Container Registry 28 | uses: docker/login-action@v1 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Build and push 35 | uses: docker/build-push-action@v2 36 | with: 37 | context: . 38 | push: ${{ github.event_name != 'pull_request' }} 39 | tags: ${{ steps.meta.outputs.tags }} 40 | labels: ${{ steps.meta.outputs.labels }} 41 | -------------------------------------------------------------------------------- /src/types/needleCommand.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { SlashCommandBuilder } from "@discordjs/builders"; 19 | import type { CommandInteraction, MessageComponentInteraction } from "discord.js"; 20 | 21 | export interface NeedleCommand { 22 | name: string; 23 | shortHelpDescription: string; 24 | longHelpDescription?: string; 25 | getSlashCommandBuilder(): Promise>; 26 | execute(interaction: CommandInteraction | MessageComponentInteraction): Promise; 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-needle", 3 | "version": "1.0.0", 4 | "description": "Needle is a discord bot that helps you manage your discord threads.", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "build": "rd /s /q dist & tsc --sourceMap", 8 | "start": "npm run build && node --enable-source-maps ./dist/index.js", 9 | "dev": "npm run build && node ./scripts/deploy-commands.js && node --enable-source-maps ./dist/index.js", 10 | "undeploy": "npm run build && node ./scripts/deploy-commands.js --undeploy", 11 | "deploy": "npm run undeploy && node ./scripts/deploy-commands.js --global" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/MarcusOtter/discord-needle.git" 16 | }, 17 | "author": "Marcus Otterström", 18 | "license": "AGPL-3.0-or-later", 19 | "bugs": { 20 | "url": "https://github.com/MarcusOtter/discord-needle/issues" 21 | }, 22 | "homepage": "https://github.com/MarcusOtter/discord-needle", 23 | "dependencies": { 24 | "@discordjs/builders": "^0.12.0", 25 | "@discordjs/rest": "^0.3.0", 26 | "discord-api-types": "^0.26.1", 27 | "discord.js": "^13.6.0", 28 | "dotenv": "^16.0.0" 29 | }, 30 | "devDependencies": { 31 | "@typescript-eslint/eslint-plugin": "^5.13.0", 32 | "@typescript-eslint/parser": "^5.13.0", 33 | "eslint": "^8.10.0", 34 | "typescript": "^4.6.2" 35 | }, 36 | "engines": { 37 | "node": ">=16.9.x", 38 | "npm": "*" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/types/messageContext.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { CacheType, Interaction, Message, TextBasedChannel, User } from "discord.js"; 19 | 20 | export interface MessageContext { 21 | interaction?: Interaction; 22 | message?: Message; 23 | 24 | // Variables that can be used in messages (if they exist at the time of invocation) 25 | // To use in message configuration, prefix with $ and convert name to SCREAMING_SNAKE_CASE 26 | // For example, $TIME_AGO and $USER 27 | channel?: TextBasedChannel; 28 | user?: User; 29 | timeAgo?: string; 30 | } 31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "es2021": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 2021, 10 | "sourceType": "module" 11 | }, 12 | "plugins": ["@typescript-eslint"], 13 | "rules": { 14 | "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], 15 | "comma-dangle": ["error", "always-multiline"], 16 | "comma-spacing": "error", 17 | "comma-style": "error", 18 | "curly": ["error", "multi-line", "consistent"], 19 | "dot-location": ["error", "property"], 20 | "handle-callback-err": "off", 21 | "indent": ["error", "tab"], 22 | "max-nested-callbacks": ["error", { "max": 4 }], 23 | "max-statements-per-line": ["error", { "max": 2 }], 24 | "no-console": "off", 25 | "no-empty-function": "error", 26 | "no-floating-decimal": "error", 27 | "no-lonely-if": "error", 28 | "no-multi-spaces": "error", 29 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], 30 | "no-shadow": "off", 31 | "@typescript-eslint/no-shadow": ["error"], 32 | "no-trailing-spaces": ["error"], 33 | "no-var": "error", 34 | "object-curly-spacing": ["error", "always"], 35 | "prefer-const": "error", 36 | "quotes": ["error", "double"], 37 | "semi": ["error", "always"], 38 | "space-before-blocks": "error", 39 | "space-before-function-paren": ["error", { 40 | "anonymous": "never", 41 | "named": "never", 42 | "asyncArrow": "always" 43 | }], 44 | "space-in-parens": "error", 45 | "space-infix-ops": "error", 46 | "space-unary-ops": "error", 47 | "spaced-comment": "error", 48 | "yoda": "error" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/handlers/interactionHandler.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import type { Interaction } from "discord.js"; 19 | import { resetMessageContext, addMessageContext } from "../helpers/messageHelpers"; 20 | import { handleButtonClickedInteraction, handleCommandInteraction } from "./commandHandler"; 21 | 22 | export async function handleInteractionCreate(interaction: Interaction): Promise { 23 | addMessageContext(interaction.id, { 24 | user: interaction.user, 25 | interaction: interaction, 26 | channel: interaction.channel ?? undefined, 27 | }); 28 | 29 | if (interaction.isCommand()) { 30 | await handleCommandInteraction(interaction); 31 | } 32 | else if (interaction.isButton()) { 33 | await handleButtonClickedInteraction(interaction); 34 | } 35 | 36 | resetMessageContext(interaction.id); 37 | } 38 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | # Privacy policy 2 | 3 | Needle has never and will never collect any personal data. This includes message content, names, IP-adresses, time stamps, member information, etc. 4 | 5 | 6 | ## What we collect 7 | We do not collect any data automatically. The only data we collect is user-provided guild configuration data for Needle. When this is provided (by invoking configuration commands), we may collect: 8 | - Guild IDs 9 | - Channel IDs 10 | - Custom responses 11 | - Which features are enabled or disabled 12 | 13 | ## Data retention 14 | The data is deleted from the main disk when one of the following occurs: 15 | - Needle is removed from the guild 16 | - A user successfully invokes the `/configure default` command 17 | - A user indicates to us by other means that they want their data deleted 18 | 19 | The data may persist in full-disk copies made for backup-purposes for up to 7 days after it was originally deleted. 20 | 21 | ## Where information is processed 22 | Needle is hosted on a data center in Helsinki, Finland by [Hetzner Online GmbH](https://www.hetzner.com/legal/privacy-policy). The bot communicates with [Discord](https://discord.com/privacy)'s API to interact with users. 23 | 24 | ## Changes to this privacy policy 25 | We reserve the right to update this privacy policy at any time, with at least 24 hours prior notice in the [Discord support server](https://needle.gg/chat). Your continued use of Needle after the changes have been applied indicates that you agree with the revised privacy policy. 26 | 27 | ## Contact 28 | Feel free to contact us at [privacy@needle.gg](mailto:privacy@needle.gg) if you have any questions about this privacy policy. 29 | 30 | Last updated and effective: February 3, 2022 - see update history 31 | -------------------------------------------------------------------------------- /src/helpers/threadHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { ThreadChannel } from "discord.js"; 2 | import { emojisEnabled } from "./configHelpers"; 3 | 4 | // If that rate limit is hit, it will wait here until it is able to rename the thread. 5 | export function setThreadName(thread: ThreadChannel, name: string): Promise { 6 | const emoji = getEmojiStatus(thread); 7 | const newName = emoji 8 | ? `${emoji} ${name}` 9 | : name; 10 | 11 | return thread.setName(newName); 12 | } 13 | 14 | // Preserves the prepended unicode emoji! 15 | // Current rate limit is 2 renames per thread per 10 minutes (2021-09-17). 16 | export async function setEmojiStatus(thread: ThreadChannel, unicodeEmoji: string): Promise { 17 | if (!isOneUnicodeEmoji(unicodeEmoji)) return false; 18 | const currentEmoji = thread.name.split(" ")[0]; 19 | // TODO: Check if there actually is a current emoji, if not just prepend the new emoji 20 | await thread.setName(thread.name.replace(currentEmoji, unicodeEmoji)); 21 | return true; 22 | } 23 | 24 | export function getEmojiStatus(thread: ThreadChannel): string | undefined { 25 | const emoji = thread.name.split(" ")[0]; 26 | return isOneUnicodeEmoji(emoji) ? emoji : undefined; 27 | } 28 | 29 | export function setEmojiForNewThread(thread: ThreadChannel, shouldBeNew: boolean): Promise { 30 | const hasNewEmoji = thread.name.includes("🆕"); 31 | if (shouldBeNew === hasNewEmoji) return Promise.resolve(thread); 32 | 33 | if (shouldBeNew && !emojisEnabled(thread.guild)) return Promise.resolve(thread); 34 | 35 | return shouldBeNew 36 | ? thread.setName(`🆕 ${thread.name}`) 37 | : thread.setName(thread.name.replaceAll("🆕", "")); 38 | } 39 | 40 | // Derived from https://stackoverflow.com/a/64007175/10615308 41 | function isOneUnicodeEmoji(text: string) { 42 | return /^\p{Extended_Pictographic}$/u.test(text); 43 | } 44 | -------------------------------------------------------------------------------- /src/types/needleConfig.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | export interface NeedleConfig { 19 | threadChannels?: AutothreadChannelConfig[]; 20 | emojisEnabled?: boolean; 21 | messages?: { 22 | ERR_UNKNOWN?: string, 23 | ERR_ONLY_IN_SERVER?: string, 24 | ERR_ONLY_IN_THREAD?: string, 25 | ERR_ONLY_THREAD_OWNER?: string, 26 | ERR_NO_EFFECT?: string, 27 | ERR_PARAMETER_MISSING?: string, 28 | ERR_INSUFFICIENT_PERMS?: string, 29 | ERR_CHANNEL_VISIBILITY?: string, 30 | ERR_CHANNEL_SLOWMODE?: string, 31 | ERR_AMBIGUOUS_THREAD_AUTHOR?: string, 32 | 33 | SUCCESS_THREAD_CREATE?: string, 34 | SUCCESS_THREAD_ARCHIVE_IMMEDIATE?: string, 35 | SUCCESS_THREAD_ARCHIVE_SLOW?: string, 36 | }, 37 | } 38 | 39 | export interface AutothreadChannelConfig { 40 | channelId: string, 41 | archiveImmediately?: boolean, 42 | messageContent?: string, 43 | includeBots?: boolean, 44 | slowmode?: number, 45 | } 46 | -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "threadChannels": [], 3 | "emojisEnabled": true, 4 | "messages": { 5 | "ERR_UNKNOWN": "An unexpected error occurred. Please try again later.", 6 | "ERR_ONLY_IN_SERVER": "You can only perform this action inside a server.", 7 | "ERR_ONLY_IN_THREAD": "You can only perform this action inside a thread.", 8 | "ERR_ONLY_THREAD_OWNER": "You need to be the thread owner to perform this action.", 9 | "ERR_NO_EFFECT": "This action will have no effect.", 10 | "ERR_JSON_MISSING": "The JSON content was missing.", 11 | "ERR_JSON_INVALID": "Your input was not valid JSON. You can use an online tool such as to validate your json.", 12 | "ERR_CONFIG_INVALID": "Your config was invalid. Remember to: \n- Pass minified JSON, because new lines inside commands does not work in Discord. You can use an online tool such as for minification.\n- Wrap the config in an object. \n- Spell property keys correctly.\n\nIf you need help with the formatting, you can see the default config of Needle at . Changes to `discordApiToken` and `dev` will be ignored by this command.", 13 | "ERR_DURATION_INVALID": "The specified duration was invalid.", 14 | "ERR_PARAMETER_MISSING": "A non-optional parameter is missing from the command.", 15 | "ERR_INSUFFICIENT_PERMS": "You do not have permission to perform this action.", 16 | "ERR_CHANNEL_VISIBILITY": "The $CHANNEL channel is not visible to the bot. Change the permissions and try again.", 17 | "ERR_CHANNEL_SLOWMODE": "The bot does not have permissions to Manage Threads in $CHANNEL. Change the permissions and try again.", 18 | "ERR_AMBIGUOUS_THREAD_AUTHOR": "Could not determine the author of this thread, because the initial message was missing.", 19 | 20 | "SUCCESS_THREAD_CREATE": "Thread automatically created by $USER in $CHANNEL.", 21 | "SUCCESS_THREAD_ARCHIVE_IMMEDIATE": "Thread was archived by $USER. Anyone can send a message to unarchive it.", 22 | "SUCCESS_THREAD_ARCHIVE_SLOW": "This thread will be archived automatically after 1 hour of inactivity as requested by $USER." 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | 5 | 6 | Needle 7 |

8 | Needle is a Discord bot that helps you declutter your server by creating Discord threads automatically. 9 |

10 | Website ✨Invite Needle 🪡Get support 💬 11 |
12 | 13 | ## Self-hosting 14 | 15 | This step-by-step guide assumes you have [NodeJS](https://nodejs.org/en/) version `16.9.0` or higher installed and that you have a Discord Bot user set up at [Discord's developer page](https://discord.com/developers/applications) that has been invited to your server with the scopes `applications.commands` and `bot`. 16 | 17 | 1. Fork and clone the repository 18 | 2. Copy `.env.example` to `.env` and insert your bot's Discord API token and Application ID. 19 | 3. Run `npm install` 20 | 4. Run `npm run deploy`. This will make the slash commands show up in the servers the bot are in, but **it can take up to _ONE HOUR_ before they show up**. 21 | 5. Make sure the bot has the required permissions in Discord: 22 | - [x] View channels 23 | - [x] Send messages 24 | - [x] Send messages in threads 25 | - [x] Create public threads 26 | - [x] Read message history 27 | 6. Run `npm start` 28 | 7. Deploy! :tada: 29 | 30 | ## Contributing 31 | 32 | Coming soon :tm: 33 | 34 | [Join the Discord](https://needle.gg/chat) if interested! 35 | 36 | ## License 37 | This program is free software: you can redistribute it and/or modify 38 | it under the terms of the GNU Affero General Public License as published by 39 | the Free Software Foundation, either version 3 of the License, or (at 40 | your option) any later version. 41 | 42 | This program is distributed in the hope that it will be useful, 43 | but WITHOUT ANY WARRANTY; without even the implied warranty of 44 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 45 | GNU Affero General Public License for more details. 46 | 47 | You should have received a copy of the GNU Affero General Public License 48 | along with this program. If not, see . 49 | -------------------------------------------------------------------------------- /src/helpers/permissionHelpers.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { type GuildMember, type NewsChannel, Permissions, type TextChannel, type ThreadAutoArchiveDuration } from "discord.js"; 19 | 20 | export function getRequiredPermissions(slowmode?: number): bigint[] { 21 | const output = [ 22 | Permissions.FLAGS.VIEW_CHANNEL, 23 | Permissions.FLAGS.SEND_MESSAGES, 24 | Permissions.FLAGS.SEND_MESSAGES_IN_THREADS, 25 | Permissions.FLAGS.CREATE_PUBLIC_THREADS, 26 | Permissions.FLAGS.READ_MESSAGE_HISTORY, 27 | ]; 28 | 29 | if (slowmode && slowmode > 0) { 30 | output.push(Permissions.FLAGS.MANAGE_THREADS); 31 | } 32 | 33 | return output; 34 | } 35 | 36 | export function memberIsModerator(member: GuildMember): boolean { 37 | return member.permissions.has(Permissions.FLAGS.KICK_MEMBERS); 38 | } 39 | 40 | export function memberIsAdmin(member: GuildMember): boolean { 41 | return member.permissions.has(Permissions.FLAGS.ADMINISTRATOR); 42 | } 43 | 44 | // Fixes https://github.com/MarcusOtter/discord-needle/issues/23 45 | // Should not be required, but Discord for some reason allows the default duration to be higher than the allowed value 46 | export function getSafeDefaultAutoArchiveDuration(channel: TextChannel | NewsChannel): ThreadAutoArchiveDuration { 47 | const archiveDuration = channel.defaultAutoArchiveDuration; 48 | if (!archiveDuration || archiveDuration === "MAX") return "MAX"; 49 | 50 | const highest = getHighestAllowedArchiveDuration(channel); 51 | return archiveDuration > highest 52 | ? highest 53 | : archiveDuration; 54 | } 55 | 56 | function getHighestAllowedArchiveDuration(channel: TextChannel | NewsChannel) { 57 | if (channel.guild.features.includes("SEVEN_DAY_THREAD_ARCHIVE")) return 10080; 58 | if (channel.guild.features.includes("THREE_DAY_THREAD_ARCHIVE")) return 4320; 59 | 60 | return 1440; // 1d 61 | } 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | import { config } from "dotenv"; 18 | config(); 19 | 20 | import { Client, Intents } from "discord.js"; 21 | import { getOrLoadAllCommands } from "./handlers/commandHandler"; 22 | import { handleInteractionCreate } from "./handlers/interactionHandler"; 23 | import { handleMessageCreate } from "./handlers/messageHandler"; 24 | import { deleteConfigsFromUnknownServers, getApiToken, resetConfigToDefault } from "./helpers/configHelpers"; 25 | 26 | console.log(`Needle, a Discord bot that declutters your server by creating threads 27 | Copyright (C) 2022 Marcus Otterström 28 | 29 | This program is free software: you can redistribute it and/or modify 30 | it under the terms of the GNU Affero General Public License as published 31 | by the Free Software Foundation, either version 3 of the License, or 32 | (at your option) any later version. 33 | 34 | This program is distributed in the hope that it will be useful, 35 | but WITHOUT ANY WARRANTY; without even the implied warranty of 36 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 37 | GNU Affero General Public License for more details. 38 | 39 | You should have received a copy of the GNU Affero General Public License 40 | along with this program. If not, see . 41 | `); 42 | 43 | (async () => { 44 | // Initial load of all commands 45 | await getOrLoadAllCommands(false); 46 | 47 | const CLIENT = new Client({ 48 | intents: [ 49 | Intents.FLAGS.GUILDS, 50 | Intents.FLAGS.GUILD_MESSAGES, 51 | ], 52 | presence: { 53 | activities: [{ 54 | type: "LISTENING", 55 | name: "/help", 56 | }], 57 | }, 58 | }); 59 | 60 | CLIENT.once("ready", () => { 61 | console.log("Ready!"); 62 | deleteConfigsFromUnknownServers(CLIENT); 63 | }); 64 | 65 | CLIENT.on("interactionCreate", async interaction => await handleInteractionCreate(interaction).catch(console.error)); 66 | CLIENT.on("messageCreate", async message => await handleMessageCreate(message).catch(console.error)); 67 | CLIENT.on("guildDelete", guild => { resetConfigToDefault(guild.id); }); 68 | 69 | CLIENT.login(getApiToken()); 70 | 71 | process.on("SIGINT", () => { 72 | CLIENT.destroy(); 73 | console.log("Destroyed client"); 74 | process.exit(0); 75 | }); 76 | })(); 77 | 78 | -------------------------------------------------------------------------------- /scripts/deploy-commands.js: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | /* eslint-disable @typescript-eslint/no-var-requires */ 19 | // You need to `tsc` before running this script. 20 | 21 | require("dotenv").config(); 22 | 23 | const { REST } = require("@discordjs/rest"); 24 | const { Routes } = require("discord-api-types/v9"); 25 | 26 | const { getOrLoadAllCommands } = require("../dist/handlers/commandHandler"); 27 | const { getApiToken, getGuildId, getClientId } = require("../dist/helpers/configHelpers"); 28 | 29 | const API_TOKEN = getApiToken(); 30 | const CLIENT_ID = getClientId(); 31 | const GUILD_ID = getGuildId(); 32 | 33 | const isGlobal = process.argv.some(x => x === "--global"); 34 | const isUndeploy = process.argv.some(x => x === "--undeploy"); 35 | 36 | if (!API_TOKEN || !CLIENT_ID) { 37 | console.log("Aborting command deployment"); 38 | console.log("DISCORD_API_TOKEN or CLIENT_ID missing from the .env file.\n"); 39 | return; 40 | } 41 | 42 | if (isUndeploy && !GUILD_ID) { 43 | console.log("Aborting undeployment of guild commands"); 44 | console.log("GUILD_ID is missing from the .env file, assuming no guild commands need to be undeployed.\n"); 45 | return; 46 | } 47 | 48 | if (!isGlobal && !GUILD_ID) { 49 | console.log("Aborting guild command deployment"); 50 | console.log("GUILD_ID is missing from the .env file."); 51 | console.log("Hint: If you just want to start the bot without developing new commands, type \"npm start\" instead\n"); 52 | return; 53 | } 54 | 55 | const route = isGlobal 56 | ? Routes.applicationCommands(CLIENT_ID) 57 | : Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID); 58 | 59 | const rest = new REST({ version: "9" }).setToken(API_TOKEN); 60 | (async () => { 61 | const builders = await getSlashCommandBuilders(); 62 | 63 | try { 64 | console.log(`Started deploying ${builders.length} application commands.`); 65 | await rest.put( 66 | route, 67 | { body: builders }, 68 | ); 69 | console.log("Successfully deployed application commands.\n"); 70 | } 71 | catch (error) { 72 | console.error(error); 73 | } 74 | })(); 75 | 76 | async function getSlashCommandBuilders() { 77 | if (isUndeploy) { 78 | console.log("Undeploying guild commands"); 79 | return []; 80 | } 81 | 82 | const allNeedleCommands = await getOrLoadAllCommands(); 83 | const allSlashCommandBuilders = []; 84 | for (const command of allNeedleCommands) { 85 | const builder = await command.getSlashCommandBuilder(); 86 | allSlashCommandBuilders.push(builder); 87 | } 88 | 89 | return allSlashCommandBuilders; 90 | } 91 | -------------------------------------------------------------------------------- /src/handlers/commandHandler.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { type CommandInteraction, type MessageComponentInteraction } from "discord.js"; 19 | import { promises } from "fs"; 20 | import { resolve as pathResolve } from "path"; 21 | import { getMessage, interactionReply } from "../helpers/messageHelpers"; 22 | import type { NeedleCommand } from "../types/needleCommand"; 23 | 24 | const COMMANDS_PATH = pathResolve(__dirname, "../commands"); 25 | 26 | let loadedCommands: NeedleCommand[] = []; 27 | 28 | export function handleCommandInteraction(interaction: CommandInteraction): Promise { 29 | const command = getCommand(interaction.commandName); 30 | if (!command) return Promise.reject(); 31 | 32 | try { 33 | return command.execute(interaction); 34 | } 35 | catch (error) { 36 | console.error(error); 37 | return interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 38 | } 39 | } 40 | 41 | export async function handleButtonClickedInteraction(interaction: MessageComponentInteraction): Promise { 42 | const command = getCommand(interaction.customId); 43 | if (!command) return Promise.reject(); 44 | 45 | try { 46 | return command.execute(interaction); 47 | } 48 | catch (error) { 49 | console.error(error); 50 | return interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 51 | } 52 | } 53 | 54 | export async function getOrLoadAllCommands(allowCache = true): Promise { 55 | if (loadedCommands.length > 0 && allowCache) { 56 | return loadedCommands; 57 | } 58 | 59 | console.log("Started reloading commands from disk."); 60 | 61 | let commandFiles = await promises.readdir(COMMANDS_PATH); 62 | commandFiles = commandFiles.filter(file => file.endsWith(".js")); 63 | const output = []; 64 | for (const file of commandFiles) { 65 | const { command } = await import(`${COMMANDS_PATH}/${file}`); 66 | output.push(command); 67 | } 68 | 69 | console.log("Successfully reloaded commands from disk."); 70 | loadedCommands = output; 71 | return output; 72 | } 73 | 74 | export function getAllLoadedCommands(): NeedleCommand[] { 75 | if (loadedCommands.length === 0) { 76 | console.error("No commands found. Did you forget to invoke \"getOrLoadAllCommands()\"?"); 77 | } 78 | 79 | return loadedCommands; 80 | } 81 | 82 | export function getCommand(commandName: string): NeedleCommand | undefined { 83 | if (loadedCommands.length === 0) { 84 | console.error("No commands found. Did you forget to invoke \"getOrLoadAllCommands()\"?"); 85 | } 86 | 87 | return loadedCommands.find(command => command.name === commandName); 88 | } 89 | -------------------------------------------------------------------------------- /src/commands/title.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { SlashCommandBuilder } from "@discordjs/builders"; 19 | import { type CommandInteraction, GuildMember, Permissions } from "discord.js"; 20 | import { interactionReply, getMessage, getThreadAuthor } from "../helpers/messageHelpers"; 21 | import { setThreadName } from "../helpers/threadHelpers"; 22 | import type { NeedleCommand } from "../types/needleCommand"; 23 | 24 | export const command: NeedleCommand = { 25 | name: "title", 26 | shortHelpDescription: "Sets the title of a thread to `value`", 27 | longHelpDescription: "The title command changes the title of a thread.", 28 | 29 | async getSlashCommandBuilder() { 30 | return new SlashCommandBuilder() 31 | .setName("title") 32 | .setDescription("Sets the title of a thread") 33 | .addStringOption(option => { 34 | return option 35 | .setName("value") 36 | .setDescription("The new title of the thread") 37 | .setRequired(true); 38 | }) 39 | .toJSON(); 40 | }, 41 | 42 | async execute(interaction: CommandInteraction): Promise { 43 | const member = interaction.member; 44 | if (!(member instanceof GuildMember)) { 45 | return interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 46 | } 47 | 48 | const channel = interaction.channel; 49 | if (!channel?.isThread()) { 50 | return interactionReply(interaction, getMessage("ERR_ONLY_IN_THREAD", interaction.id)); 51 | } 52 | 53 | const newThreadName = interaction.options.getString("value"); 54 | if (!newThreadName) { 55 | return interactionReply(interaction, getMessage("ERR_PARAMETER_MISSING", interaction.id)); 56 | } 57 | 58 | const oldThreadName = channel.name; 59 | if (oldThreadName === newThreadName) { 60 | return interactionReply(interaction, getMessage("ERR_NO_EFFECT", interaction.id)); 61 | } 62 | 63 | const hasChangeTitlePermissions = member 64 | .permissionsIn(channel) 65 | .has(Permissions.FLAGS.MANAGE_THREADS, true); 66 | 67 | if (hasChangeTitlePermissions) { 68 | await setThreadName(channel, newThreadName); 69 | await interactionReply(interaction, "Success!"); 70 | return; 71 | } 72 | 73 | const threadAuthor = await getThreadAuthor(channel); 74 | if (!threadAuthor) { 75 | return interactionReply(interaction, getMessage("ERR_AMBIGUOUS_THREAD_AUTHOR", interaction.id)); 76 | } 77 | 78 | if (threadAuthor !== interaction.user) { 79 | return interactionReply(interaction, getMessage("ERR_ONLY_THREAD_OWNER", interaction.id)); 80 | } 81 | 82 | await setThreadName(channel, newThreadName); 83 | await interactionReply(interaction, "Success!"); 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /src/commands/close.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { SlashCommandBuilder } from "@discordjs/builders"; 19 | import { type CommandInteraction, GuildMember, type MessageComponentInteraction, Permissions, type ThreadChannel } from "discord.js"; 20 | import { shouldArchiveImmediately } from "../helpers/configHelpers"; 21 | import { interactionReply, getMessage, getThreadAuthor } from "../helpers/messageHelpers"; 22 | import { setEmojiForNewThread } from "../helpers/threadHelpers"; 23 | import type { NeedleCommand } from "../types/needleCommand"; 24 | 25 | export const command: NeedleCommand = { 26 | name: "close", 27 | shortHelpDescription: "Closes a thread by setting the auto-archive duration to 1 hour", 28 | longHelpDescription: "The close command sets the auto-archive duration to 1 hour in a thread.\n\nWhen using auto-archive, the thread will automatically be archived when there have been no new messages in the thread for one hour. This can be undone by a server moderator by manually changing the auto-archive duration back to what it was previously, using Discord's own interface.", 29 | 30 | async getSlashCommandBuilder() { 31 | return new SlashCommandBuilder() 32 | .setName("close") 33 | .setDescription("Closes a thread by setting the auto-archive duration to 1 hour") 34 | .toJSON(); 35 | }, 36 | 37 | async execute(interaction: CommandInteraction | MessageComponentInteraction): Promise { 38 | const member = interaction.member; 39 | if (!(member instanceof GuildMember)) { 40 | return interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 41 | } 42 | 43 | const channel = interaction.channel; 44 | if (!channel?.isThread()) { 45 | return interactionReply(interaction, getMessage("ERR_ONLY_IN_THREAD", interaction.id)); 46 | } 47 | 48 | // Invoking slash commands seem to unarchive the threads for now so ironically, this has no effect 49 | // Leaving this in if Discord decides to change their API around this 50 | if (channel.archived) { 51 | return interactionReply(interaction, getMessage("ERR_NO_EFFECT", interaction.id)); 52 | } 53 | 54 | const hasManageThreadsPermissions = member.permissionsIn(channel).has(Permissions.FLAGS.MANAGE_THREADS, true); 55 | if (hasManageThreadsPermissions) { 56 | await archiveThread(channel); 57 | return; 58 | } 59 | 60 | const threadAuthor = await getThreadAuthor(channel); 61 | if (!threadAuthor) { 62 | return interactionReply(interaction, getMessage("ERR_AMBIGUOUS_THREAD_AUTHOR", interaction.id)); 63 | } 64 | 65 | if (threadAuthor !== interaction.user) { 66 | return interactionReply(interaction, getMessage("ERR_ONLY_THREAD_OWNER", interaction.id)); 67 | } 68 | 69 | await archiveThread(channel); 70 | 71 | async function archiveThread(thread: ThreadChannel): Promise { 72 | if (shouldArchiveImmediately(thread)) { 73 | if (interaction.isButton()) { 74 | await interaction.update({ content: interaction.message.content }); 75 | const message = getMessage("SUCCESS_THREAD_ARCHIVE_IMMEDIATE", interaction.id); 76 | if (message) { 77 | await thread.send(message); 78 | } 79 | } 80 | else if (interaction.isCommand()) { 81 | await interactionReply(interaction, getMessage("SUCCESS_THREAD_ARCHIVE_IMMEDIATE", interaction.id), false); 82 | } 83 | 84 | await setEmojiForNewThread(thread, false); 85 | await thread.setArchived(true); 86 | return; 87 | } 88 | 89 | if (thread.autoArchiveDuration === 60) { 90 | return interactionReply(interaction, getMessage("ERR_NO_EFFECT", interaction.id)); 91 | } 92 | 93 | await setEmojiForNewThread(thread, false); 94 | await thread.setAutoArchiveDuration(60); 95 | 96 | if (interaction.isButton()) { 97 | await interaction.update({ content: interaction.message.content }); 98 | const message = getMessage("SUCCESS_THREAD_ARCHIVE_SLOW", interaction.id); 99 | if (message) { 100 | await thread.send(message); 101 | } 102 | } 103 | else if (interaction.isCommand()) { 104 | await interactionReply(interaction, getMessage("SUCCESS_THREAD_ARCHIVE_SLOW", interaction.id), false); 105 | } 106 | } 107 | }, 108 | }; 109 | -------------------------------------------------------------------------------- /src/handlers/messageHandler.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { type Message, MessageActionRow, MessageButton, NewsChannel, TextChannel, ThreadChannel, SnowflakeUtil, type Snowflake, Permissions } from "discord.js"; 19 | import { emojisEnabled, getConfig, includeBotsForAutothread, getSlowmodeSeconds } from "../helpers/configHelpers"; 20 | import { getMessage, resetMessageContext, addMessageContext, isAutoThreadChannel, getHelpButton, replaceMessageVariables, getThreadAuthor } from "../helpers/messageHelpers"; 21 | import { getRequiredPermissions, getSafeDefaultAutoArchiveDuration } from "../helpers/permissionHelpers"; 22 | 23 | export async function handleMessageCreate(message: Message): Promise { 24 | // Server outage 25 | if (!message.guild?.available) return; 26 | 27 | // Not logged in 28 | if (message.client.user === null) return; 29 | 30 | if (message.system) return; 31 | if (!message.channel.isText()) return; 32 | if (!message.inGuild()) return; 33 | if (message.author.id === message.client.user.id) return; 34 | 35 | const includeBots = includeBotsForAutothread(message.guild.id, message.channel.id); 36 | if (!includeBots && message.author.bot) return; 37 | 38 | if (!message.author.bot && message.channel.isThread()) { 39 | await updateTitle(message.channel, message); 40 | return; 41 | } 42 | 43 | const requestId = SnowflakeUtil.generate(); 44 | await autoCreateThread(message, requestId); 45 | resetMessageContext(requestId); 46 | } 47 | 48 | async function updateTitle(thread: ThreadChannel, message: Message) { 49 | if (message.author.bot) return; 50 | 51 | const threadAuthor = await getThreadAuthor(thread); 52 | if (message.author == threadAuthor) return; 53 | 54 | await thread.setName(thread.name.replace("🆕", "")); 55 | } 56 | 57 | async function autoCreateThread(message: Message, requestId: Snowflake) { 58 | // Server outage 59 | if (!message.guild?.available) return; 60 | 61 | // Not logged in 62 | if (message.client.user === null) return; 63 | 64 | const authorUser = message.author; 65 | const authorMember = message.member; 66 | const guild = message.guild; 67 | const channel = message.channel; 68 | 69 | if (!(channel instanceof TextChannel) && !(channel instanceof NewsChannel)) return; 70 | if (message.hasThread) return; 71 | if (!isAutoThreadChannel(channel.id, guild.id)) return; 72 | 73 | const slowmode = getSlowmodeSeconds(guild.id, channel.id); 74 | 75 | const botMember = await guild.members.fetch(message.client.user); 76 | const botPermissions = botMember.permissionsIn(message.channel.id); 77 | const requiredPermissions = getRequiredPermissions(slowmode); 78 | if (!botPermissions.has(requiredPermissions)) { 79 | try { 80 | const missing = botPermissions.missing(requiredPermissions); 81 | const errorMessage = `Missing permission${missing.length > 1 ? "s" : ""}:`; 82 | await message.channel.send(`${errorMessage}\n - ${missing.join("\n - ")}`); 83 | } 84 | catch (e) { 85 | console.log(e); 86 | } 87 | return; 88 | } 89 | 90 | addMessageContext(requestId, { 91 | user: authorUser, 92 | channel: channel, 93 | message: message, 94 | }); 95 | 96 | const creationDate = message.createdAt.toISOString().slice(0, 10); 97 | const authorName = authorMember === null || authorMember.nickname === null 98 | ? authorUser.username 99 | : authorMember.nickname; 100 | 101 | const name = emojisEnabled(guild) 102 | ? `🆕 ${authorName} (${creationDate})` 103 | : `${authorName} (${creationDate})`; 104 | 105 | const thread = await message.startThread({ 106 | name, 107 | rateLimitPerUser: slowmode, 108 | autoArchiveDuration: getSafeDefaultAutoArchiveDuration(channel), 109 | }); 110 | 111 | const closeButton = new MessageButton() 112 | .setCustomId("close") 113 | .setLabel("Archive thread") 114 | .setStyle("SUCCESS") 115 | .setEmoji("937932140014866492"); // :archive: 116 | 117 | const helpButton = getHelpButton(); 118 | 119 | const buttonRow = new MessageActionRow().addComponents(closeButton, helpButton); 120 | 121 | const overrideMessageContent = getConfig(guild.id).threadChannels?.find(x => x?.channelId === channel.id)?.messageContent; 122 | const msgContent = overrideMessageContent 123 | ? replaceMessageVariables(overrideMessageContent, requestId) 124 | : getMessage("SUCCESS_THREAD_CREATE", requestId); 125 | 126 | if (msgContent && msgContent.length > 0) { 127 | const msg = await thread.send({ 128 | content: msgContent, 129 | components: [buttonRow], 130 | }); 131 | 132 | if (botMember.permissionsIn(thread.id).has(Permissions.FLAGS.MANAGE_MESSAGES)) { 133 | await msg.pin(); 134 | await thread.lastMessage?.delete(); 135 | } 136 | } 137 | 138 | resetMessageContext(requestId); 139 | } 140 | -------------------------------------------------------------------------------- /src/commands/help.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { SlashCommandBuilder } from "@discordjs/builders"; 19 | import { type CommandInteraction, MessageActionRow, MessageEmbed } from "discord.js"; 20 | import type { APIApplicationCommandOption } from "discord-api-types"; 21 | import { getCommand, getOrLoadAllCommands } from "../handlers/commandHandler"; 22 | import { getBugReportButton, getDiscordInviteButton, getFeatureRequestButton } from "../helpers/messageHelpers"; 23 | import type { NeedleCommand } from "../types/needleCommand"; 24 | 25 | export const command: NeedleCommand = { 26 | name: "help", 27 | shortHelpDescription: "", // Help command has a special treatment of help description 28 | longHelpDescription: "The help command shows you a list of all available commands. If you provide a command after `/help`, it will show you more information about that specific command (exactly like you just did!).", 29 | 30 | getSlashCommandBuilder() { 31 | return getHelpSlashCommandBuilder(); 32 | }, 33 | 34 | async execute(interaction: CommandInteraction): Promise { 35 | const row = new MessageActionRow() 36 | .addComponents( 37 | getDiscordInviteButton(), 38 | getBugReportButton(), 39 | getFeatureRequestButton()); 40 | 41 | const commandName = interaction.options?.getString("command"); 42 | if (commandName) { // User wrote for example "/help title" 43 | const commandsEmbed = await getCommandDetailsEmbed(commandName); 44 | await interaction.reply({ 45 | embeds: commandsEmbed, 46 | components: [row], 47 | ephemeral: true, 48 | }); 49 | } 50 | else { // User only wrote "/help" 51 | const commandsEmbed = await getAllCommandsEmbed(); 52 | await interaction.reply({ 53 | embeds: [commandsEmbed], 54 | components: [row], 55 | ephemeral: true, 56 | }); 57 | } 58 | }, 59 | }; 60 | 61 | async function getCommandDetailsEmbed(commandName: string): Promise { 62 | const cmd = getCommand(commandName); 63 | if (!cmd) { return []; } 64 | 65 | const cmdOptionString = await getCommandOptionString(cmd); 66 | const cmdOptions = await getCommandOptions(cmd); 67 | let cmdOptionExplanations = ""; 68 | for (const option of cmdOptions ?? []) { 69 | cmdOptionExplanations += `\`${option.name}\` - ${option.required ? "" : "(optional)"} ${option.description}\n`; 70 | } 71 | 72 | const commandInfoEmbed = new MessageEmbed() 73 | .setTitle(`Information about \`/${cmd.name}\``) 74 | .setDescription(cmd.longHelpDescription ?? cmd.shortHelpDescription) 75 | .addField("Usage", `/${cmd.name}${cmdOptionString}`, false); 76 | 77 | if (cmdOptionExplanations && cmdOptionExplanations.length > 0) { 78 | commandInfoEmbed.addField("Options", cmdOptionExplanations, false); 79 | } 80 | 81 | return [commandInfoEmbed]; 82 | } 83 | 84 | async function getAllCommandsEmbed(): Promise { 85 | const embed = new MessageEmbed().setTitle("🪡 Needle Commands"); // :sewing_needle: 86 | const commands = await getOrLoadAllCommands(); 87 | for (const cmd of commands) { 88 | // Help command gets special treatment 89 | if (cmd.name === "help") { 90 | embed.addField("/help", "Shows a list of all available commands", false); 91 | embed.addField("/help `command`", "Shows more information and example usage of a specific `command`", false); 92 | continue; 93 | } 94 | const commandOptions = await getCommandOptionString(cmd); 95 | embed.addField(`/${cmd.name}${commandOptions}`, cmd.shortHelpDescription, false); 96 | } 97 | return embed; 98 | } 99 | 100 | async function getCommandOptionString(cmd: NeedleCommand): Promise { 101 | const commandInfo = await cmd.getSlashCommandBuilder(); 102 | if (!commandInfo.options) { return ""; } 103 | 104 | let output = ""; 105 | for (const option of commandInfo.options) { 106 | output += ` \`${option.name}${option.required ? "" : "?"}\``; 107 | } 108 | return output; 109 | } 110 | 111 | async function getCommandOptions(cmd: NeedleCommand): Promise { 112 | const commandInfo = await cmd.getSlashCommandBuilder(); 113 | return commandInfo.options; 114 | } 115 | 116 | async function getHelpSlashCommandBuilder() { 117 | const commands = await getOrLoadAllCommands(); 118 | const builder = new SlashCommandBuilder() 119 | .setName("help") 120 | .setDescription("Shows a list of all available commands") 121 | .addStringOption(option => { 122 | option 123 | .setName("command") 124 | .setDescription("The specific command you want help with. Exclude this option to get a list of all commands.") 125 | .setRequired(false); 126 | 127 | for (const cmd of commands) { 128 | option.addChoice(cmd.name, cmd.name); 129 | } 130 | 131 | return option; 132 | }); 133 | 134 | return builder.toJSON(); 135 | } 136 | -------------------------------------------------------------------------------- /src/helpers/messageHelpers.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { 19 | type BaseCommandInteraction, 20 | type Message, 21 | MessageButton, 22 | type MessageComponentInteraction, 23 | type TextBasedChannel, 24 | type ThreadChannel, 25 | type User, 26 | type Snowflake, 27 | } from "discord.js"; 28 | 29 | import type { MessageContext } from "../types/messageContext"; 30 | import type { NeedleConfig } from "../types/needleConfig"; 31 | import { getConfig } from "./configHelpers"; 32 | 33 | const contexts: Map = new Map(); 34 | 35 | export type MessageKey = keyof NonNullable; 36 | 37 | export function addMessageContext(requestId: Snowflake, additionalContext: Partial): void { 38 | const currentContext = contexts.get(requestId); 39 | const newContext = Object.assign(currentContext ?? {}, additionalContext); 40 | contexts.set(requestId, newContext); 41 | } 42 | 43 | export function resetMessageContext(requestSnowflake: Snowflake): void { 44 | contexts.delete(requestSnowflake); 45 | } 46 | 47 | export function isAutoThreadChannel(channelId: string, guildId: string): boolean { 48 | const config = getConfig(guildId); 49 | return config?.threadChannels?.some(x => x?.channelId === channelId) ?? false; 50 | } 51 | 52 | export async function getThreadAuthor(channel: ThreadChannel): Promise { 53 | const parentMessage = await getThreadStartMessage(channel); 54 | 55 | if (parentMessage) return parentMessage.author; 56 | 57 | // https://github.com/MarcusOtter/discord-needle/issues/49 58 | const firstMessage = await getFirstMessageInChannel(channel); 59 | const author = firstMessage?.mentions.users.first(); 60 | 61 | if (!author) console.log(`Could not determine author of thread "${channel.name}"`); 62 | return author; 63 | } 64 | 65 | export async function getFirstMessageInChannel(channel: TextBasedChannel): Promise { 66 | const amount = channel.isThread() ? 2 : 1; // threads have an empty message as the first message 67 | const messages = await channel.messages.fetch({ after: "0", limit: amount }); 68 | return messages.first(); 69 | } 70 | 71 | export function interactionReply( 72 | interaction: BaseCommandInteraction | MessageComponentInteraction, 73 | message?: string, 74 | ephemeral = true): Promise { 75 | if (!message || message.length == 0) { 76 | return interaction.reply({ 77 | content: getMessage("ERR_UNKNOWN", interaction.id), 78 | ephemeral: true, 79 | }); 80 | } 81 | 82 | return interaction.reply({ 83 | content: message, 84 | ephemeral: ephemeral, 85 | }); 86 | } 87 | 88 | export function getMessage(messageKey: MessageKey, requestId: Snowflake | undefined, replaceVariables = true): string | undefined { 89 | const context = contexts.get(requestId ?? ""); 90 | const config = getConfig(context?.interaction?.guildId ?? undefined); 91 | if (!config.messages) { return ""; } 92 | 93 | const message = config.messages[messageKey]; 94 | if (!context || !message) { return message; } 95 | 96 | return replaceVariables 97 | ? replaceMessageVariables(message, requestId ?? "") 98 | : message; 99 | } 100 | 101 | export function replaceMessageVariables(message: string, requestId: Snowflake): string { 102 | const context = contexts.get(requestId); 103 | if (!context) return message; 104 | 105 | const user = context.user ? `<@${context.user.id}>` : ""; 106 | const channel = context.channel ? `<#${context.channel.id}>` : ""; 107 | const timeAgo = context.timeAgo || (context.message 108 | ? `` 109 | : ""); 110 | 111 | return message 112 | .replaceAll("$USER", user) 113 | .replaceAll("$CHANNEL", channel) 114 | .replaceAll("$TIME_AGO", timeAgo) 115 | .replaceAll("\\n", "\n"); 116 | } 117 | 118 | export function getDiscordInviteButton(buttonText = "Join the support server"): MessageButton { 119 | return new MessageButton() 120 | .setLabel(buttonText) 121 | .setStyle("LINK") 122 | .setURL("https://discord.gg/8BmnndXHp6") 123 | .setEmoji("930584823473516564"); // :discord_light: 124 | } 125 | 126 | export function getGithubRepoButton(buttonText = "Source code"): MessageButton { 127 | return new MessageButton() 128 | .setLabel(buttonText) 129 | .setStyle("LINK") 130 | .setURL("https://github.com/MarcusOtter/discord-needle/") 131 | .setEmoji("888980150077755412"); // :github_light: 132 | } 133 | 134 | export function getBugReportButton(buttonText = "Report a bug"): MessageButton { 135 | return new MessageButton() 136 | .setLabel(buttonText) 137 | .setStyle("LINK") 138 | .setURL("https://github.com/MarcusOtter/discord-needle/issues/new/choose") 139 | .setEmoji("🐛"); 140 | } 141 | 142 | export function getFeatureRequestButton(buttonText = "Suggest an improvement"): MessageButton { 143 | return new MessageButton() 144 | .setLabel(buttonText) 145 | .setStyle("LINK") 146 | .setURL("https://github.com/MarcusOtter/discord-needle/issues/new/choose") 147 | .setEmoji("💡"); 148 | } 149 | 150 | export function getHelpButton(): MessageButton { 151 | return new MessageButton() 152 | .setCustomId("help") 153 | .setLabel("Commands") 154 | .setStyle("SECONDARY") 155 | .setEmoji("937931337942306877"); // :slash_commands: 156 | } 157 | 158 | async function getThreadStartMessage(threadChannel: TextBasedChannel | null): Promise { 159 | if (!threadChannel?.isThread()) { return null; } 160 | if (!threadChannel.parentId) { return null; } 161 | 162 | const parentChannel = await threadChannel.guild?.channels.fetch(threadChannel.parentId); 163 | if (!parentChannel?.isText()) { return null; } 164 | 165 | // The thread's channel ID is the same as the start message's ID, 166 | // but if the start message has been deleted this will throw an exception 167 | return parentChannel.messages 168 | .fetch(threadChannel.id) 169 | .catch(() => null); 170 | } 171 | -------------------------------------------------------------------------------- /src/helpers/configHelpers.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import type { Client, Guild, ThreadChannel } from "discord.js"; 19 | import * as defaultConfig from "../config.json"; 20 | import { resolve as pathResolve } from "path"; 21 | import * as fs from "fs"; 22 | import type { NeedleConfig } from "../types/needleConfig"; 23 | import { MessageKey } from "./messageHelpers"; 24 | 25 | const CONFIGS_PATH = pathResolve(__dirname, "../../", process.env.CONFIGS_PATH || "configs"); 26 | const guildConfigsCache = new Map(); 27 | 28 | export function getConfig(guildId = ""): NeedleConfig { 29 | const guildConfig = guildConfigsCache.get(guildId) ?? readConfigFromFile(guildId); 30 | 31 | const defaultConfigCopy = JSON.parse(JSON.stringify(defaultConfig)) as NeedleConfig; 32 | if (guildConfig) { 33 | guildConfig.messages = Object.assign({}, defaultConfigCopy.messages, guildConfig?.messages); 34 | } 35 | 36 | return Object.assign({}, defaultConfigCopy, guildConfig); 37 | } 38 | 39 | // Can probably remove the three methods below :) 40 | 41 | // Used by deploy-commands.js (!) 42 | export function getApiToken(): string | undefined { 43 | return process.env.DISCORD_API_TOKEN; 44 | } 45 | 46 | // Used by deploy-commands.js (!) 47 | export function getClientId(): string | undefined { 48 | return process.env.CLIENT_ID; 49 | } 50 | 51 | // Used by deploy-commands.js (!) 52 | export function getGuildId(): string | undefined { 53 | return process.env.GUILD_ID; 54 | } 55 | 56 | export function shouldArchiveImmediately(thread: ThreadChannel) { 57 | const config = getConfig(thread.guildId); 58 | return config?.threadChannels?.find(x => x.channelId === thread.parentId)?.archiveImmediately ?? true; 59 | } 60 | 61 | export function includeBotsForAutothread(guildId: string, channelId: string) { 62 | const config = getConfig(guildId); 63 | return config?.threadChannels?.find(x => x.channelId === channelId)?.includeBots ?? false; 64 | } 65 | 66 | export function setEmojisEnabled(guild: Guild, enabled: boolean): boolean { 67 | const config = getConfig(guild.id); 68 | config.emojisEnabled = enabled; 69 | return setConfig(guild, config); 70 | } 71 | 72 | export function emojisEnabled(guild: Guild): boolean { 73 | const config = getConfig(guild.id); 74 | return config.emojisEnabled ?? true; 75 | } 76 | 77 | export function setMessage(guild: Guild, messageKey: MessageKey, value: string): boolean { 78 | const config = getConfig(guild.id); 79 | if (!config || !config.messages) { return false; } 80 | if (value.length > 2000) { return false; } 81 | 82 | config.messages[messageKey] = value; 83 | return setConfig(guild, config); 84 | } 85 | 86 | export function getSlowmodeSeconds(guildId: string, channelId: string) { 87 | const config = getConfig(guildId); 88 | return config?.threadChannels?.find(x => x.channelId === channelId)?.slowmode ?? 0; 89 | } 90 | 91 | export function enableAutothreading(guild: Guild, channelId: string, includeBots?: boolean, archiveImmediately?: boolean, messageContent?: string, slowmode?: number): boolean { 92 | const config = getConfig(guild.id); 93 | if (!config || !config.threadChannels) { return false; } 94 | if ((messageContent?.length ?? 0) > 2000) { return false; } 95 | 96 | const index = config.threadChannels.findIndex(x => x?.channelId === channelId); 97 | if (index > -1) { 98 | if (includeBots !== undefined) config.threadChannels[index].includeBots = includeBots; 99 | if (archiveImmediately !== undefined) config.threadChannels[index].archiveImmediately = archiveImmediately; 100 | if (messageContent !== undefined) config.threadChannels[index].messageContent = messageContent; 101 | if (slowmode !== undefined) config.threadChannels[index].slowmode = slowmode; 102 | } 103 | else { 104 | config.threadChannels.push({ channelId, includeBots, archiveImmediately, messageContent, slowmode }); 105 | } 106 | return setConfig(guild, config); 107 | } 108 | 109 | export function disableAutothreading(guild: Guild, channelId: string): boolean { 110 | const config = getConfig(guild.id); 111 | if (!config || !config.threadChannels) { return false; } 112 | 113 | const index = config.threadChannels.findIndex(x => x?.channelId === channelId); 114 | if (index > -1) { 115 | delete config.threadChannels[index]; 116 | } 117 | 118 | return setConfig(guild, config); 119 | } 120 | 121 | export function resetConfigToDefault(guildId: string): boolean { 122 | const path = getGuildConfigPath(guildId); 123 | if (!fs.existsSync(path)) return false; 124 | fs.rmSync(path); 125 | guildConfigsCache.delete(guildId); 126 | console.log(`Deleted data for guild ${guildId}`); 127 | return true; 128 | } 129 | 130 | export function deleteConfigsFromUnknownServers(client: Client): void { 131 | if (!client.guilds.cache.size) { 132 | console.warn("No guilds available; skipping config deletion."); 133 | return; 134 | } 135 | 136 | if (!fs.existsSync(CONFIGS_PATH)) return; 137 | 138 | const configFiles = fs.readdirSync(CONFIGS_PATH); 139 | configFiles.forEach(file => { 140 | const guildId = file.split(".")[0]; 141 | if (!client.guilds.cache.has(guildId)) { 142 | resetConfigToDefault(guildId); 143 | } 144 | }); 145 | } 146 | 147 | function readConfigFromFile(guildId: string): NeedleConfig | undefined { 148 | const path = getGuildConfigPath(guildId); 149 | if (!fs.existsSync(path)) return undefined; 150 | 151 | const jsonConfig = fs.readFileSync(path, { "encoding": "utf-8" }); 152 | return JSON.parse(jsonConfig); 153 | } 154 | 155 | function getGuildConfigPath(guildId: string) { 156 | return `${CONFIGS_PATH}/${guildId}.json`; 157 | } 158 | 159 | function setConfig(guild: Guild | null | undefined, config: NeedleConfig): boolean { 160 | if (!guild || !config) return false; 161 | 162 | const path = getGuildConfigPath(guild.id); 163 | if (!fs.existsSync(CONFIGS_PATH)) { 164 | fs.mkdirSync(CONFIGS_PATH); 165 | } 166 | config.threadChannels = config.threadChannels?.filter(val => val != null && val != undefined); 167 | 168 | // Only save messages that are different from the defaults 169 | const defaultConfigCopy = JSON.parse(JSON.stringify(defaultConfig)) as NeedleConfig; 170 | if (defaultConfigCopy.messages && config.messages) { 171 | for(const [key, message] of Object.entries(config.messages)) { 172 | if (message !== defaultConfigCopy.messages[key as MessageKey]) continue; 173 | delete config.messages[key as MessageKey]; 174 | } 175 | } 176 | 177 | fs.writeFileSync(path, JSON.stringify(config), { encoding: "utf-8" }); 178 | guildConfigsCache.set(guild.id, config); 179 | return true; 180 | } 181 | -------------------------------------------------------------------------------- /src/commands/configure.ts: -------------------------------------------------------------------------------- 1 | // ________________________________________________________________________________________________ 2 | // 3 | // This file is part of Needle. 4 | // 5 | // Needle is free software: you can redistribute it and/or modify it under the terms of the GNU 6 | // Affero General Public License as published by the Free Software Foundation, either version 3 of 7 | // the License, or (at your option) any later version. 8 | // 9 | // Needle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even 10 | // the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 11 | // General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU Affero General Public License along with Needle. 14 | // If not, see . 15 | // 16 | // ________________________________________________________________________________________________ 17 | 18 | import { SlashCommandBuilder } from "@discordjs/builders"; 19 | import { ChannelType } from "discord-api-types"; 20 | import { type CommandInteraction, type GuildMember, type GuildTextBasedChannel, Permissions } from "discord.js"; 21 | import { disableAutothreading, emojisEnabled, enableAutothreading, getConfig, resetConfigToDefault, setEmojisEnabled, setMessage } from "../helpers/configHelpers"; 22 | import { interactionReply, getMessage, MessageKey, isAutoThreadChannel, addMessageContext } from "../helpers/messageHelpers"; 23 | import type { NeedleCommand } from "../types/needleCommand"; 24 | import { memberIsModerator } from "../helpers/permissionHelpers"; 25 | 26 | // Note: 27 | // The important messages of these commands should not be configurable 28 | // (prevents user made soft-locks where it's hard to figure out how to fix it) 29 | 30 | export const command: NeedleCommand = { 31 | name: "configure", 32 | shortHelpDescription: "Modify the configuration of Needle", 33 | 34 | async getSlashCommandBuilder() { 35 | return new SlashCommandBuilder() 36 | .setName("configure") 37 | .setDescription("Modify the configuration of Needle") 38 | .addSubcommand(subcommand => { 39 | return subcommand 40 | .setName("message") 41 | .setDescription("Modify the content of a message that Needle replies with when a certain action happens") 42 | .addStringOption(option => { 43 | const opt = option 44 | .setName("key") 45 | .setDescription("The key of the message") 46 | .setRequired(true); 47 | 48 | for(const messageKey of Object.keys(getConfig().messages ?? [])) { 49 | opt.addChoice(messageKey, messageKey); 50 | } 51 | 52 | return opt; 53 | }) 54 | .addStringOption(option => { 55 | return option 56 | .setName("value") 57 | .setDescription("The new message for the selected key (shows the current value of this message key if left blank)") 58 | .setRequired(false); 59 | }); 60 | }) 61 | .addSubcommand(subcommand => { 62 | return subcommand 63 | .setName("default") 64 | .setDescription("Reset the server's custom Needle configuration to the default"); 65 | }) 66 | .addSubcommand(subcommand => { 67 | return subcommand 68 | .setName("auto-threading") 69 | .setDescription("Enable or disable automatic creation of threads on every new message in a channel") 70 | .addChannelOption(option => { 71 | return option 72 | .setName("channel") 73 | .setDescription("The channel to enable/disable automatic threading in") 74 | .addChannelType(ChannelType.GuildText) 75 | .addChannelType(ChannelType.GuildNews) 76 | .setRequired(true); 77 | }) 78 | .addBooleanOption(option => { 79 | return option 80 | .setName("enabled") 81 | .setDescription("Whether or not threads should be automatically created from new messages in the selected channel") 82 | .setRequired(true); 83 | }) 84 | .addBooleanOption(option => { 85 | return option 86 | .setName("include-bots") 87 | .setDescription("Whether or not threads should be created on messages by bots. Default: False"); 88 | }) 89 | .addStringOption(option => { 90 | return option 91 | .setName("archive-behavior") 92 | .setDescription("What should happen when users close a thread?") 93 | .addChoice("✅ Archive immediately (DEFAULT)", "immediately") 94 | .addChoice("⌛ Archive after 1 hour of inactivity", "slow"); 95 | }) 96 | .addStringOption(option => { 97 | return option 98 | .setName("slowmode") 99 | .setDescription("The default slowmode option for new threads") 100 | .addChoice("Off (DEFAULT)", "0") 101 | .addChoice("30 seconds", "30") 102 | .addChoice("1 minute", "60") 103 | .addChoice("5 minutes", "300") 104 | .addChoice("15 minutes", "900") 105 | .addChoice("1 hour", "3600") 106 | .addChoice("6 hours", "21600"); 107 | }) 108 | .addStringOption(option => { 109 | return option 110 | .setName("custom-message") 111 | .setDescription("The message to send when a thread is created (\"\\n\" for new line)"); 112 | }); 113 | }) 114 | .addSubcommand(subcommand => { 115 | return subcommand 116 | .setName("emojis") 117 | .setDescription("Toggle thread name emojis on or off") 118 | .addBooleanOption(option => { 119 | return option 120 | .setName("enabled") 121 | .setDescription("Whether or not emojis should be enabled for titles in auto-threads"); 122 | }); 123 | }) 124 | .toJSON(); 125 | }, 126 | 127 | async execute(interaction: CommandInteraction): Promise { 128 | if (!interaction.guildId || !interaction.guild) { 129 | return interactionReply(interaction, getMessage("ERR_ONLY_IN_SERVER", interaction.id)); 130 | } 131 | 132 | if (!memberIsModerator(interaction.member as GuildMember)) { 133 | return interactionReply(interaction, getMessage("ERR_INSUFFICIENT_PERMS", interaction.id)); 134 | } 135 | 136 | if (interaction.options.getSubcommand() === "default") { 137 | const success = resetConfigToDefault(interaction.guild.id); 138 | return interactionReply(interaction, success 139 | ? "Successfully reset the Needle configuration to the default." 140 | : getMessage("ERR_NO_EFFECT", interaction.id), !success); 141 | } 142 | 143 | if (interaction.options.getSubcommand() === "emojis") { 144 | return configureEmojis(interaction); 145 | } 146 | 147 | if (interaction.options.getSubcommand() === "message") { 148 | return configureMessage(interaction); 149 | } 150 | 151 | if (interaction.options.getSubcommand() === "auto-threading") { 152 | return configureAutothreading(interaction); 153 | } 154 | 155 | return interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 156 | }, 157 | }; 158 | 159 | function configureEmojis(interaction: CommandInteraction): Promise { 160 | const enable = interaction.options.getBoolean("enabled"); 161 | if (enable === null || interaction.guild === null) { 162 | return interactionReply(interaction, getMessage("ERR_PARAMETER_MISSING", interaction.id)); 163 | } 164 | 165 | if (enable === emojisEnabled(interaction.guild)) { 166 | return interactionReply(interaction, getMessage("ERR_NO_EFFECT", interaction.id)); 167 | } 168 | 169 | const success = setEmojisEnabled(interaction.guild, enable); 170 | if (!success) return interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 171 | 172 | return interactionReply(interaction, enable 173 | ? "Successfully enabled emojis." 174 | : "Successfully disabled emojis."); 175 | } 176 | 177 | function configureMessage(interaction: CommandInteraction): Promise { 178 | const key = interaction.options.getString("key") as MessageKey; 179 | const value = interaction.options.getString("value"); 180 | 181 | if (!interaction.guild) { 182 | return interactionReply(interaction, getMessage("ERR_ONLY_IN_SERVER", interaction.id)); 183 | } 184 | 185 | if (!value || value.length === 0) { 186 | return interactionReply(interaction, `**${key}** message:\n\n>>> ${getMessage(key, interaction.id, false)}`); 187 | } 188 | 189 | const oldValue = getMessage(key, interaction.id, false); 190 | return setMessage(interaction.guild, key, value) 191 | ? interactionReply(interaction, `Changed **${key}**\n\nOld message:\n> ${oldValue?.replaceAll("\n", "\n> ")}\n\nNew message:\n>>> ${value}`, false) 192 | : interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 193 | } 194 | 195 | async function configureAutothreading(interaction: CommandInteraction): Promise { 196 | const channel = interaction.options.getChannel("channel") as GuildTextBasedChannel; 197 | const enabled = interaction.options.getBoolean("enabled"); 198 | const customMessage = interaction.options.getString("custom-message") ?? ""; 199 | const archiveImmediately = interaction.options.getString("archive-behavior") !== "slow"; 200 | const includeBots = interaction.options.getBoolean("include-bots") ?? false; 201 | const slowmode = parseInt(interaction.options.getString("slowmode") ?? "0"); 202 | 203 | if (!interaction.guild || !interaction.guildId) { 204 | return interactionReply(interaction, getMessage("ERR_ONLY_IN_SERVER", interaction.id)); 205 | } 206 | 207 | if (!channel || enabled == null) { 208 | return interactionReply(interaction, getMessage("ERR_PARAMETER_MISSING", interaction.id)); 209 | } 210 | 211 | const clientUser = interaction.client.user; 212 | if (!clientUser) return interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 213 | 214 | const botMember = await interaction.guild.members.fetch(clientUser); 215 | const botPermissions = botMember.permissionsIn(channel.id); 216 | 217 | if (!botPermissions.has(Permissions.FLAGS.VIEW_CHANNEL)) { 218 | addMessageContext(interaction.id, { channel }); 219 | return interactionReply(interaction, getMessage("ERR_CHANNEL_VISIBILITY", interaction.id)); 220 | } 221 | 222 | if (slowmode && slowmode > 0 && !botPermissions.has(Permissions.FLAGS.MANAGE_THREADS)) { 223 | addMessageContext(interaction.id, { channel }); 224 | return interactionReply(interaction, getMessage("ERR_CHANNEL_SLOWMODE", interaction.id)); 225 | } 226 | 227 | if (enabled) { 228 | const success = enableAutothreading(interaction.guild, channel.id, includeBots, archiveImmediately, customMessage, slowmode); 229 | return success 230 | ? interactionReply(interaction, `Updated auto-threading settings for <#${channel.id}>`, false) 231 | : interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 232 | } 233 | 234 | if (!isAutoThreadChannel(channel.id, interaction.guildId)) { 235 | return interactionReply(interaction, getMessage("ERR_NO_EFFECT", interaction.id)); 236 | } 237 | 238 | const success = disableAutothreading(interaction.guild, channel.id); 239 | return success 240 | ? interactionReply(interaction, `Removed auto-threading in <#${channel.id}>`, false) 241 | : interactionReply(interaction, getMessage("ERR_UNKNOWN", interaction.id)); 242 | } 243 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | --------------------------------------------------------------------------------