├── .python-version ├── docker ├── .env.example ├── rootfs │ ├── docker-entrypoint.sh │ └── docker-env2conf.py └── compose.yaml ├── cappuccino ├── db │ ├── alembic │ │ ├── README │ │ ├── script.py.mako │ │ ├── __init__.py │ │ ├── versions │ │ │ ├── __init__.py │ │ │ ├── 65969d0d94f3_rename_trigger_trigger_to_trigger_name.py │ │ │ ├── 4dc2c5515277_rename_corpuschannel_status_to_.py │ │ │ ├── a71bc86773a8_make_ai_channels_status_and_ai_corpus_.py │ │ │ ├── b99364b9b9c3_update_last_seen_timestamp_type.py │ │ │ ├── 3668c91fa898_add_relationship_to_ai_models.py │ │ │ ├── 5ebc3fcc5af8_drop_chanlog_and_ricedb_data.py │ │ │ ├── ca58ba59b328_.py │ │ │ ├── c7308e3c814a_seperate_ricedb_json_into_columns.py │ │ │ └── 15a2d5ea1971_switch_all_string_types_to_text.py │ │ └── env.py │ └── models │ │ ├── __init__.py │ │ ├── triggers.py │ │ ├── ai.py │ │ └── userdb.py ├── util │ ├── __init__.py │ ├── meta.py │ ├── channel.py │ └── formatting.py ├── sentry.py ├── nickserv.py ├── uptimekuma.py ├── catfacts.py ├── seen.py ├── execshell.py ├── __init__.py ├── botui.py ├── userdb.py ├── sed.py ├── influx.py ├── triggers.py ├── lastfm.py ├── fun.py ├── ai.py ├── rice.py └── urlinfo.py ├── logo.png ├── .yamllint.yaml ├── .dockerignore ├── .editorconfig ├── .gitignore ├── .github └── workflows │ ├── lint.yml │ └── build-docker-image.yml ├── README.md ├── .pre-commit-config.yaml ├── Dockerfile ├── pyproject.toml ├── alembic.ini ├── config.dist.ini └── LICENSE /.python-version: -------------------------------------------------------------------------------- 1 | 3.14 2 | -------------------------------------------------------------------------------- /docker/.env.example: -------------------------------------------------------------------------------- 1 | # CFG_BOT_NICK= 2 | # CFG_IRC_HOST= 3 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheReverend403/cappuccino/HEAD/logo.png -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: "default" 3 | ignore-from-file: ".gitignore" 4 | rules: 5 | line-length: "disable" 6 | quoted-strings: 7 | quote-type: "double" 8 | comments: 9 | min-spaces-from-content: 1 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | 3 | !docker/rootfs/* 4 | !cappuccino 5 | !alembic.ini 6 | !pyproject.toml 7 | !uv.lock 8 | !.python-version 9 | !README.md 10 | !LICENSE 11 | 12 | **/__pycache__ 13 | **/*.py[cod] 14 | **/*$py.class 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 4 8 | max_line_length = 120 9 | 10 | [*.py] 11 | max_line_length = 88 12 | 13 | [*.{yml,yaml}] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docker/data/* 2 | docker/config/* 3 | 4 | .vscode/ 5 | .idea/ 6 | 7 | .ruff_cache/ 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # Environments 15 | .env 16 | .venv 17 | env/ 18 | venv/ 19 | ENV/ 20 | env.bak/ 21 | venv.bak/ 22 | 23 | !/.git* 24 | -------------------------------------------------------------------------------- /docker/rootfs/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | /docker-env2conf.py "${SETTINGS_SOURCE_FILE}" "${SETTINGS_FILE}" 5 | 6 | alembic upgrade head 7 | 8 | RUN_COMMAND="irc3" 9 | if [ "${DEBUG:-false}" = "true" ]; then 10 | RUN_COMMAND="${RUN_COMMAND} -dr" 11 | fi 12 | 13 | exec ${RUN_COMMAND} "${SETTINGS_FILE}" 14 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /cappuccino/util/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | # 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Lint" 3 | 4 | on: # yamllint disable-line rule:truthy 5 | - "workflow_call" 6 | 7 | env: 8 | RUFF_OUTPUT_FORMAT: "github" 9 | UV_FROZEN: 1 10 | 11 | jobs: 12 | lint: 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - uses: "actions/checkout@v4" 16 | 17 | - name: "Install uv" 18 | uses: "astral-sh/setup-uv@v6" 19 | with: 20 | enable-cache: true 21 | 22 | - name: "Set up Python" 23 | uses: "actions/setup-python@v5" 24 | with: 25 | python-version-file: ".python-version" 26 | 27 | - name: "Install Python dependencies" 28 | run: "uv sync --group dev" 29 | 30 | - name: "Lint" 31 | uses: "pre-commit/action@v3.0.1" 32 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | # 16 | -------------------------------------------------------------------------------- /cappuccino/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | from sqlalchemy.orm import DeclarativeBase 17 | 18 | 19 | class BaseModel(DeclarativeBase): 20 | pass 21 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/65969d0d94f3_rename_trigger_trigger_to_trigger_name.py: -------------------------------------------------------------------------------- 1 | """Rename Trigger.trigger to Trigger.name 2 | 3 | Revision ID: 65969d0d94f3 4 | Revises: 4dc2c5515277 5 | Create Date: 2025-05-06 15:59:16.078293 6 | 7 | """ 8 | 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "65969d0d94f3" 13 | down_revision = "4dc2c5515277" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column("triggers", "trigger", nullable=False, new_column_name="name") 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.alter_column("triggers", "name", nullable=False, new_column_name="trigger") 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/4dc2c5515277_rename_corpuschannel_status_to_.py: -------------------------------------------------------------------------------- 1 | """Rename CorpusChannel.status to CorpusChannel.enabled 2 | 3 | Revision ID: 4dc2c5515277 4 | Revises: a71bc86773a8 5 | Create Date: 2025-05-06 15:53:34.642464 6 | 7 | """ 8 | 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "4dc2c5515277" 13 | down_revision = "a71bc86773a8" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column("ai_channels", "status", nullable=False, new_column_name="enabled") 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.alter_column("ai_channels", "enabled", nullable=False, new_column_name="status") 27 | 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /cappuccino/util/meta.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import os 17 | 18 | FULL_VERSION: str = "[version unknown]" 19 | VERSION: str | None = os.getenv("META_VERSION") 20 | COMMIT: str | None = os.getenv("META_COMMIT") 21 | SOURCE: str = os.getenv("META_SOURCE") or "https://github.com/lwatsondev/cappuccino" 22 | 23 | if VERSION: 24 | FULL_VERSION = f"{VERSION}" 25 | 26 | if COMMIT: 27 | FULL_VERSION += f"-{COMMIT[:8]}" 28 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/a71bc86773a8_make_ai_channels_status_and_ai_corpus_.py: -------------------------------------------------------------------------------- 1 | """make ai_channels.status and ai_corpus.channel non-nullable 2 | 3 | Revision ID: a71bc86773a8 4 | Revises: 5ebc3fcc5af8 5 | Create Date: 2025-05-06 15:43:59.978947 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "a71bc86773a8" 15 | down_revision = "5ebc3fcc5af8" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.alter_column("ai_channels", "status", existing_type=sa.BOOLEAN(), nullable=False) 23 | op.alter_column("ai_corpus", "channel", existing_type=sa.VARCHAR(), nullable=False) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.alter_column("ai_corpus", "channel", existing_type=sa.VARCHAR(), nullable=True) 30 | op.alter_column("ai_channels", "status", existing_type=sa.BOOLEAN(), nullable=True) 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/b99364b9b9c3_update_last_seen_timestamp_type.py: -------------------------------------------------------------------------------- 1 | """Update last_seen timestamp type 2 | 3 | Revision ID: b99364b9b9c3 4 | Revises: c7308e3c814a 5 | Create Date: 2025-05-06 02:17:30.035596 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "b99364b9b9c3" 16 | down_revision = "c7308e3c814a" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.alter_column( 24 | "ricedb", 25 | "last_seen", 26 | existing_type=postgresql.TIMESTAMP(), 27 | type_=sa.DateTime(timezone=True), 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.alter_column( 35 | "ricedb", 36 | "last_seen", 37 | existing_type=sa.DateTime(timezone=True), 38 | type_=postgresql.TIMESTAMP(), 39 | ) 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/3668c91fa898_add_relationship_to_ai_models.py: -------------------------------------------------------------------------------- 1 | """Add relationship to ai models 2 | 3 | Revision ID: 3668c91fa898 4 | Revises: 65969d0d94f3 5 | Create Date: 2025-05-06 16:48:41.711015 6 | 7 | """ 8 | 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "3668c91fa898" 13 | down_revision = "65969d0d94f3" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.alter_column( 21 | "ai_corpus", "channel", nullable=False, new_column_name="channel_name" 22 | ) 23 | op.create_foreign_key( 24 | "fk_ai_corpus.channel_name", 25 | "ai_corpus", 26 | "ai_channels", 27 | ["channel_name"], 28 | ["name"], 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.alter_column( 36 | "ai_corpus", "channel_name", nullable=False, new_column_name="channel" 37 | ) 38 | op.drop_constraint("fk_ai_corpus.channel_name", "ai_corpus", type_="foreignkey") 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /cappuccino/db/models/triggers.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | from sqlalchemy import Text 17 | from sqlalchemy.orm import Mapped, mapped_column 18 | 19 | from cappuccino.db.models import BaseModel 20 | 21 | 22 | class Trigger(BaseModel): 23 | __tablename__ = "triggers" 24 | 25 | name: Mapped[str] = mapped_column(Text, nullable=False, primary_key=True) 26 | channel: Mapped[str] = mapped_column(Text, nullable=False, primary_key=True) 27 | response: Mapped[str] = mapped_column(Text, nullable=False, unique=False) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | logo 3 |

4 | 5 |

6 | GitHub 7 | GitHub Workflow Status 8 | Code style: ruff 9 |

10 | 11 |

12 | A set of irc3 plugins providing various utilities primarily for #rice@irc.rizon.net. 13 |

14 | 15 | # Installation 16 | 17 | Requirements: 18 | 19 | - PostgreSQL 20 | 21 | ## Setting up the dev environment 22 | 23 | First, install [uv](https://docs.astral.sh/uv/getting-started/installation/). 24 | 25 | ```sh 26 | git clone https://github.com/lwatsondev/cappuccino 27 | cd cappuccino 28 | 29 | uv sync --group dev 30 | uv run pre-commit install 31 | ``` 32 | 33 | ## Running in dev mode 34 | 35 | ```sh 36 | cp docker/.env.example docker/.env # Open and set any empty variables 37 | docker compose -f docker/compose.yaml up --build --pull always 38 | ``` 39 | -------------------------------------------------------------------------------- /cappuccino/util/channel.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | from enum import Enum 17 | 18 | 19 | class ChannelMode(Enum): 20 | VOICE = "+" 21 | HALF_OP = "%" 22 | OP = "@" 23 | SUPER_OP = "&" 24 | OWNER = "~" 25 | 26 | 27 | def is_chanop(botcontext, channel: str, nick: str) -> bool: 28 | """Checks whether a user is a chanop (has mode +h or above).""" 29 | for mode in ChannelMode: 30 | # Voiced users aren't channel operators. 31 | if mode is ChannelMode.VOICE: 32 | continue 33 | 34 | try: 35 | if nick in botcontext.channels[channel].modes[mode.value]: 36 | return True 37 | except (KeyError, AttributeError): 38 | continue 39 | 40 | return False 41 | -------------------------------------------------------------------------------- /cappuccino/db/models/ai.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | from sqlalchemy import Boolean, ForeignKey, Text 17 | from sqlalchemy.orm import Mapped, mapped_column, relationship 18 | 19 | from cappuccino.db.models import BaseModel 20 | 21 | 22 | class CorpusLine(BaseModel): 23 | __tablename__ = "ai_corpus" 24 | 25 | line: Mapped[str] = mapped_column(Text, nullable=False, primary_key=True) 26 | channel_name: Mapped[str] = mapped_column( 27 | Text, ForeignKey("ai_channels.name"), nullable=False 28 | ) 29 | channel: Mapped[AIChannel] = relationship(back_populates="lines") 30 | 31 | 32 | class AIChannel(BaseModel): 33 | __tablename__ = "ai_channels" 34 | 35 | name: Mapped[str] = mapped_column(Text, nullable=False, primary_key=True) 36 | enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 37 | lines: Mapped[list[CorpusLine]] = relationship(back_populates="channel") 38 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: "local" 4 | hooks: 5 | - id: "end-of-file-fixer" 6 | name: "end-of-file-fixer" 7 | entry: "uv run end-of-file-fixer" 8 | language: "system" 9 | types: 10 | - "text" 11 | 12 | - id: "trailing-whitespace-fixer" 13 | name: "trailing-whitespace-fixer" 14 | entry: "uv run trailing-whitespace-fixer" 15 | language: "system" 16 | types: 17 | - "text" 18 | 19 | - id: "check-toml" 20 | name: "check-toml" 21 | entry: "uv run check-toml" 22 | language: "system" 23 | types: 24 | - "toml" 25 | 26 | - id: "check-shebang-scripts-are-executable" 27 | name: "check-shebang-scripts-are-executable" 28 | entry: "uv run check-shebang-scripts-are-executable" 29 | language: "system" 30 | types: 31 | - "text" 32 | 33 | - id: "yamllint" 34 | name: "yamllint" 35 | entry: "uv run yamllint" 36 | args: 37 | - "--strict" 38 | language: "system" 39 | types: 40 | - "yaml" 41 | 42 | - id: "shellcheck" 43 | name: "shellcheck" 44 | entry: "uv run shellcheck" 45 | language: "system" 46 | types: 47 | - "shell" 48 | 49 | - id: "ruff-check" 50 | name: "ruff-check" 51 | entry: "uv run ruff check" 52 | args: 53 | - "--fix" 54 | language: "system" 55 | types: 56 | - "python" 57 | 58 | - id: "ruff-format" 59 | name: "ruff-format" 60 | entry: "uv run ruff format" 61 | language: "system" 62 | types: 63 | - "python" 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ARG DEBIAN_VERSION=trixie 4 | 5 | ## Base 6 | FROM debian:${DEBIAN_VERSION}-slim AS python-base 7 | 8 | ENV PYTHONUNBUFFERED=1 \ 9 | UV_COMPILE_BYTECODE=1 \ 10 | UV_LINK_MODE=copy \ 11 | UV_FROZEN=1 \ 12 | UV_PROJECT_ENVIRONMENT="/opt/uv/venv" \ 13 | UV_PYTHON_INSTALL_DIR="/opt/uv/python" \ 14 | UV_CACHE_DIR="/opt/uv/cache" 15 | 16 | ENV PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}" \ 17 | PYTHONPATH="/app" 18 | 19 | WORKDIR /app 20 | 21 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ 22 | COPY .python-version . 23 | 24 | RUN --mount=type=cache,target=${UV_CACHE_DIR} \ 25 | uv python install 26 | 27 | 28 | ## Base image 29 | FROM python-base AS app-base 30 | 31 | RUN --mount=type=cache,target=/var/cache/apt,sharing=private \ 32 | apt-get update && apt-get install --no-install-recommends -y \ 33 | ca-certificates \ 34 | libpq5 \ 35 | && rm -rf /var/lib/apt/lists/* 36 | 37 | ARG META_VERSION 38 | ARG META_COMMIT 39 | ARG META_SOURCE 40 | 41 | ENV META_VERSION="${META_VERSION}" \ 42 | META_COMMIT="${META_COMMIT}" \ 43 | META_SOURCE="${META_SOURCE}" \ 44 | SETTINGS_FILE="/tmp/config.ini" \ 45 | SETTINGS_SOURCE_FILE="/config/config.ini" 46 | 47 | ADD . . 48 | RUN ln -s /app/docker/rootfs/* / 49 | 50 | RUN --mount=type=cache,target=${UV_CACHE_DIR} \ 51 | uv sync --no-install-project --no-dev --group docker 52 | 53 | VOLUME ["/config"] 54 | EXPOSE 1337 55 | 56 | ENTRYPOINT ["/docker-entrypoint.sh"] 57 | 58 | 59 | FROM app-base AS development 60 | 61 | ENV DEBUG=true 62 | 63 | RUN --mount=type=cache,target=${UV_CACHE_DIR} \ 64 | uv sync --no-install-project --group docker 65 | 66 | 67 | FROM app-base AS production 68 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/5ebc3fcc5af8_drop_chanlog_and_ricedb_data.py: -------------------------------------------------------------------------------- 1 | """Drop chanlog and ricedb.data 2 | 3 | Revision ID: 5ebc3fcc5af8 4 | Revises: b99364b9b9c3 5 | Create Date: 2025-05-06 15:42:22.244462 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "5ebc3fcc5af8" 16 | down_revision = "b99364b9b9c3" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.drop_table("chanlog") 24 | op.drop_column("ricedb", "data") 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.add_column( 31 | "ricedb", 32 | sa.Column( 33 | "data", 34 | postgresql.JSON(astext_type=sa.Text()), 35 | autoincrement=False, 36 | nullable=True, 37 | ), 38 | ) 39 | op.create_table( 40 | "chanlog", 41 | sa.Column("user", sa.VARCHAR(), autoincrement=False, nullable=True), 42 | sa.Column("channel", sa.VARCHAR(), autoincrement=False, nullable=True), 43 | sa.Column("event", sa.VARCHAR(), autoincrement=False, nullable=True), 44 | sa.Column("target", sa.VARCHAR(), autoincrement=False, nullable=True), 45 | sa.Column("data", sa.VARCHAR(), autoincrement=False, nullable=True), 46 | sa.Column( 47 | "time", 48 | postgresql.TIMESTAMP(), 49 | server_default=sa.text("now()"), 50 | autoincrement=False, 51 | nullable=True, 52 | ), 53 | ) 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /cappuccino/sentry.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import irc3 17 | import sentry_sdk 18 | from requests import RequestException 19 | from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration 20 | 21 | from cappuccino import Plugin 22 | from cappuccino.util import meta 23 | 24 | 25 | def _before_send(event, hint): 26 | if "exc_info" in hint: 27 | _, exc_value, _ = hint["exc_info"] 28 | if isinstance(exc_value, RequestException | TimeoutError): 29 | return None 30 | 31 | return event 32 | 33 | 34 | @irc3.plugin 35 | class Sentry(Plugin): 36 | requires = ["irc3.plugins.command"] 37 | 38 | def __init__(self, bot): 39 | super().__init__(bot) 40 | 41 | dsn = self.config.get("dsn", None) 42 | if not dsn: 43 | self.logger.info("Missing Sentry DSN, Sentry is disabled.") 44 | return 45 | 46 | sentry_sdk.init( 47 | dsn, 48 | before_send=_before_send, 49 | release=meta.FULL_VERSION, 50 | integrations=[SqlalchemyIntegration()], 51 | ) 52 | -------------------------------------------------------------------------------- /cappuccino/nickserv.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import irc3 17 | 18 | from cappuccino import Plugin 19 | 20 | 21 | @irc3.plugin 22 | class NickServ(Plugin): 23 | @irc3.event( 24 | r":(?PNickServ)!\S+@\S+ NOTICE .* :This nickname is registered.*" 25 | ) 26 | def login_attempt(self, nickserv): 27 | password = self.config.get("password", None) 28 | if not password: 29 | self.logger.warning( 30 | "This nick is registered but no nickserv password is set in config.ini" 31 | ) 32 | return 33 | 34 | self.bot.privmsg(nickserv, f"IDENTIFY {password}") 35 | 36 | @irc3.event(r":(?PNickServ!\S+@\S+) NOTICE .* :Password accepted.*") 37 | def login_succeeded(self, mask): 38 | self.logger.info(f"Authenticated with {mask}") 39 | 40 | @irc3.event(r":(?PNickServ!\S+@\S+) NOTICE .* :Password incorrect.*") 41 | def login_failed(self, mask): 42 | self.logger.warning( 43 | f"Failed to authenticate with {mask} due to an incorrect password" 44 | ) 45 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/ca58ba59b328_.py: -------------------------------------------------------------------------------- 1 | """empty messageTrue 2 | 3 | Revision ID: ca58ba59b328 4 | Revises: 5 | Create Date: 2020-01-24 02:44:22.085585 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "ca58ba59b328" 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "ai_channels", 24 | sa.Column("name", sa.String(), nullable=False), 25 | sa.Column("status", sa.Boolean(), nullable=True), 26 | sa.PrimaryKeyConstraint("name"), 27 | ) 28 | op.create_table( 29 | "ai_corpus", 30 | sa.Column("line", sa.String(), nullable=False), 31 | sa.Column("channel", sa.String(), nullable=True), 32 | sa.PrimaryKeyConstraint("line"), 33 | ) 34 | op.create_table( 35 | "chanlog", 36 | sa.Column("user", sa.String(), nullable=True), 37 | sa.Column("channel", sa.String(), nullable=True), 38 | sa.Column("event", sa.String(), nullable=True), 39 | sa.Column("target", sa.String(), nullable=True), 40 | sa.Column("data", sa.String(), nullable=True), 41 | sa.Column( 42 | "time", sa.DateTime(), server_default=sa.text("now()"), nullable=True 43 | ), 44 | ) 45 | op.create_table( 46 | "ricedb", 47 | sa.Column("nick", sa.String(), nullable=False), 48 | sa.Column("data", sa.JSON(), nullable=True), 49 | sa.PrimaryKeyConstraint("nick"), 50 | ) 51 | op.create_table( 52 | "triggers", 53 | sa.Column("trigger", sa.String(), nullable=False), 54 | sa.Column("channel", sa.String(), nullable=False), 55 | sa.Column("response", sa.String(), nullable=False), 56 | ) 57 | # ### end Alembic commands ### 58 | 59 | 60 | def downgrade(): 61 | # ### commands auto generated by Alembic - please adjust! ### 62 | op.drop_table("triggers") 63 | op.drop_table("ricedb") 64 | op.drop_table("chanlog") 65 | op.drop_table("ai_corpus") 66 | op.drop_table("ai_channels") 67 | # ### end Alembic commands ### 68 | -------------------------------------------------------------------------------- /cappuccino/uptimekuma.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import asyncio 17 | 18 | import irc3 19 | from httpx import URL, AsyncClient, HTTPError 20 | from irc3 import rfc 21 | 22 | from cappuccino import Plugin 23 | 24 | 25 | @irc3.plugin 26 | class UptimeKuma(Plugin): 27 | def __init__(self, bot): 28 | super().__init__(bot) 29 | self._webhook: str = self.config.get("webhook", None) 30 | self._interval: int = self.config.get("interval", 30) 31 | 32 | @irc3.event(rfc.CONNECTED) 33 | def _on_connect( 34 | self, srv: str | None = None, me: str | None = None, data: str | None = None 35 | ): 36 | if self._webhook: 37 | self.bot.create_task(self._ping_loop()) 38 | 39 | async def ping(self, message: str = "OK", status: str = "up"): 40 | async with AsyncClient(timeout=5) as client: 41 | request_params = {"status": status, "msg": message} 42 | request_url = URL(self._webhook, params=request_params) 43 | self.logger.debug(f"Pinging {request_url}") 44 | try: 45 | response = await client.get(request_url) 46 | response.raise_for_status() 47 | self.logger.debug("Ping succeeded.") 48 | except HTTPError: 49 | self.logger.exception("Ping failed.") 50 | 51 | async def _ping_loop(self): 52 | self.logger.info(f"Pinging Uptime Kuma every {self._interval} seconds.") 53 | while True: 54 | await self.ping() 55 | await asyncio.sleep(self._interval) 56 | -------------------------------------------------------------------------------- /docker/rootfs/docker-env2conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This file is part of cappuccino. 4 | # 5 | # cappuccino is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # cappuccino is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with cappuccino. If not, see . 17 | 18 | import argparse 19 | import errno 20 | import logging 21 | import os 22 | import sys 23 | from argparse import Namespace 24 | from pathlib import Path 25 | 26 | from jinja2 import Environment, FileSystemLoader, select_autoescape 27 | 28 | logging.basicConfig( 29 | format="[%(levelname)-5s] %(name)s: %(message)s", level=logging.INFO 30 | ) 31 | log = logging.getLogger(Path(__file__).name) 32 | 33 | 34 | def parse_args() -> Namespace: 35 | parser = argparse.ArgumentParser() 36 | parser.add_argument("source", type=Path) 37 | parser.add_argument("destination", type=Path) 38 | return parser.parse_args() 39 | 40 | 41 | def main(): 42 | args = parse_args() 43 | source_file: Path = args.source 44 | destination_file: Path = args.destination 45 | 46 | log.info(f"Templating {source_file} out to {destination_file}") 47 | 48 | env = Environment( 49 | loader=FileSystemLoader(source_file.parent), autoescape=select_autoescape() 50 | ) 51 | 52 | try: 53 | template = env.get_template(source_file.name) 54 | rendered = template.render( 55 | **{key: val for (key, val) in os.environ.items() if key.startswith("CFG_")} 56 | ) 57 | destination_file.write_text(rendered) 58 | log.info("Done.") 59 | except FileNotFoundError as exc: 60 | log.error(f"{exc.filename} does not exist.") # noqa: TRY400 61 | sys.exit(errno.ENOENT) 62 | 63 | 64 | if __name__ == "__main__": 65 | main() 66 | -------------------------------------------------------------------------------- /cappuccino/util/formatting.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import ircstyle 17 | 18 | 19 | class Color: 20 | WHITE = ircstyle.colors.white 21 | BLACK = ircstyle.colors.black 22 | BLUE = ircstyle.colors.blue 23 | GREEN = ircstyle.colors.green 24 | RED = ircstyle.colors.red 25 | BROWN = ircstyle.colors.brown 26 | PURPLE = ircstyle.colors.purple 27 | ORANGE = ircstyle.colors.orange 28 | YELLOW = ircstyle.colors.yellow 29 | LIGHT_GREEN = ircstyle.colors.light_green 30 | TEAL = ircstyle.colors.teal 31 | LIGHT_CYAN = ircstyle.colors.light_cyan 32 | LIGHT_BLUE = ircstyle.colors.light_blue 33 | PINK = ircstyle.colors.pink 34 | GRAY = ircstyle.colors.grey 35 | LIME = ircstyle.colors.lime 36 | LIGHT_GRAY = ircstyle.colors.light_grey 37 | 38 | 39 | def style( # noqa: PLR0913 40 | text, 41 | fg: Color | None = None, 42 | bg: Color | None = None, 43 | *, 44 | bold=False, 45 | italics=False, 46 | underline=False, 47 | reset=True, 48 | ) -> str: 49 | return ircstyle.style( 50 | str(text), 51 | fg=fg, 52 | bg=bg, 53 | italics=italics, 54 | underline=underline, 55 | bold=bold, 56 | reset=reset, 57 | ) 58 | 59 | 60 | def unstyle(text) -> str: 61 | return ircstyle.unstyle(str(text)) 62 | 63 | 64 | def truncate_with_ellipsis(text: str, max_length: int) -> str: 65 | """Truncates a string to max_length - 3 and adds an ellipsis.""" 66 | if len(text) > max_length: 67 | return "".join(text[: max_length - 3]) + "..." 68 | return text 69 | -------------------------------------------------------------------------------- /cappuccino/db/models/userdb.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | from datetime import datetime # noqa: TC003 17 | 18 | from sqlalchemy import JSON, DateTime, Text, func 19 | from sqlalchemy.orm import Mapped, mapped_column 20 | 21 | from cappuccino.db.models import BaseModel 22 | 23 | 24 | class RiceDB(BaseModel): 25 | __tablename__ = "ricedb" 26 | 27 | nick: Mapped[str] = mapped_column(Text, nullable=False, primary_key=True) 28 | dtops: Mapped[JSON | None] = mapped_column( 29 | JSON, 30 | nullable=True, 31 | ) 32 | homescreens: Mapped[JSON | None] = mapped_column( 33 | JSON, 34 | nullable=True, 35 | ) 36 | stations: Mapped[JSON | None] = mapped_column( 37 | JSON, 38 | nullable=True, 39 | ) 40 | pets: Mapped[JSON | None] = mapped_column( 41 | JSON, 42 | nullable=True, 43 | ) 44 | dotfiles: Mapped[JSON | None] = mapped_column( 45 | JSON, 46 | nullable=True, 47 | ) 48 | handwritings: Mapped[JSON | None] = mapped_column( 49 | JSON, 50 | nullable=True, 51 | ) 52 | distros: Mapped[JSON | None] = mapped_column( 53 | JSON, 54 | nullable=True, 55 | ) 56 | websites: Mapped[JSON | None] = mapped_column( 57 | JSON, 58 | nullable=True, 59 | ) 60 | selfies: Mapped[JSON | None] = mapped_column( 61 | JSON, 62 | nullable=True, 63 | ) 64 | lastfm: Mapped[str] = mapped_column(Text, nullable=True) 65 | last_seen: Mapped[datetime] = mapped_column( 66 | DateTime(timezone=True), nullable=True, server_default=func.now() 67 | ) 68 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-image.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Build and push Docker image" 3 | 4 | on: # yamllint disable-line rule:truthy 5 | push: 6 | branches: 7 | - "main" 8 | tags: 9 | - "*.*" 10 | pull_request: 11 | branches: 12 | - "main" 13 | workflow_call: 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}" 17 | cancel-in-progress: true 18 | 19 | env: 20 | REGISTRY: "ghcr.io" 21 | IMAGE_NAME: "${{ github.repository }}" 22 | 23 | jobs: 24 | lint: 25 | uses: "./.github/workflows/lint.yml" 26 | build-and-push-image: 27 | runs-on: "ubuntu-latest" 28 | needs: 29 | - "lint" 30 | permissions: 31 | contents: "read" 32 | packages: "write" 33 | steps: 34 | - uses: "actions/checkout@v4" 35 | 36 | - name: "Set up Docker Buildx" 37 | uses: "docker/setup-buildx-action@v3" 38 | 39 | - name: "Log in to the container registry" 40 | if: "${{ github.event_name != 'pull_request' && !env.ACT }}" 41 | uses: "docker/login-action@v3" 42 | with: 43 | registry: "${{ env.REGISTRY }}" 44 | username: "${{ github.actor }}" 45 | password: "${{ secrets.GITHUB_TOKEN }}" 46 | 47 | - name: "Extract metadata (tags, labels) for Docker" 48 | id: "meta" 49 | uses: "docker/metadata-action@v5" 50 | with: 51 | images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" 52 | tags: | 53 | type=ref,event=branch 54 | type=ref,event=pr 55 | type=semver,pattern={{version}} 56 | type=semver,pattern={{major}}.{{minor}} 57 | 58 | - name: "Build and push Docker image" 59 | uses: "docker/build-push-action@v6" 60 | with: 61 | context: "." 62 | push: "${{ github.event_name != 'pull_request' && !env.ACT }}" 63 | tags: "${{ steps.meta.outputs.tags }}" 64 | labels: "${{ steps.meta.outputs.labels }}" 65 | build-args: |- 66 | META_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} 67 | META_COMMIT=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} 68 | META_SOURCE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.source'] }} 69 | -------------------------------------------------------------------------------- /docker/compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "cappuccino" 3 | 4 | services: 5 | cappuccino: 6 | container_name: "cappuccino" 7 | restart: "no" 8 | init: true 9 | read_only: true 10 | user: "${PUID:-1000}:${PGID:-1000}" 11 | depends_on: 12 | db: 13 | condition: "service_healthy" 14 | env_file: ".env" 15 | environment: 16 | TZ: "${TZ:-UTC}" 17 | CFG_BOT_NICK: "${CFG_BOT_NICK:-cappuccino_dev}" 18 | CFG_BOT_REALNAME: "${CFG_BOT_REALNAME:-cappuccino_dev}" 19 | CFG_BOT_USERNAME: "${CFG_BOT_USERNAME:-cappuccino_dev}" 20 | CFG_IRC_HOST: "${CFG_IRC_HOST:?}" 21 | CFG_IRC_PORT: "${CFG_IRC_PORT:-6697}" 22 | CFG_IRC_SSL: "${CFG_IRC_SSL:-true}" 23 | CFG_DB_URI: "${CFG_DB_URI:-postgresql+psycopg://${POSTGRES_USER:-cappuccino}:${POSTGRES_PASSWORD:-cappuccino}@${POSTGRES_HOST:-db}/${POSTGRES_DB:-cappuccino}}" 24 | volumes: 25 | - "./config/app:/config" 26 | - "../:/app" 27 | tmpfs: 28 | - "/tmp" 29 | networks: 30 | - "cappuccino" 31 | ports: 32 | - "1337:1337" 33 | build: 34 | context: "../" 35 | target: "development" 36 | deploy: 37 | resources: 38 | limits: 39 | cpus: "${CAPPUCCINO_CPU_LIMIT:-${CPU_LIMIT:-0}}" 40 | memory: "${CAPPUCCINO_MEM_LIMIT:-${MEM_LIMIT:-0}}" 41 | 42 | db: 43 | image: "postgres:17-alpine" 44 | container_name: "cappuccino_db" 45 | restart: "no" 46 | init: true 47 | user: "${PUID:-1000}:${PGID:-1000}" 48 | environment: 49 | TZ: "${TZ:-UTC}" 50 | POSTGRES_HOST: "${POSTGRES_HOST:-db}" 51 | POSTGRES_DB: "${POSTGRES_DB:-cappuccino}" 52 | POSTGRES_USER: "${POSTGRES_USER:-cappuccino}" 53 | POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-cappuccino}" 54 | volumes: 55 | - "./data/postgresql:/var/lib/postgresql/data" 56 | networks: 57 | - "cappuccino" 58 | healthcheck: 59 | test: 60 | - "CMD-SHELL" 61 | - "pg_isready -q --dbname=$$POSTGRES_DB --username=$$POSTGRES_USER --host=$$POSTGRES_HOST" 62 | start_period: "5s" 63 | start_interval: "1s" 64 | interval: "10s" 65 | timeout: "1s" 66 | deploy: 67 | resources: 68 | limits: 69 | cpus: "${POSTGRES_CPU_LIMIT:-${CPU_LIMIT:-0}}" 70 | memory: "${POSTGRES_MEM_LIMIT:-${MEM_LIMIT:-0}}" 71 | 72 | networks: 73 | cappuccino: 74 | -------------------------------------------------------------------------------- /cappuccino/catfacts.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import random 17 | 18 | import irc3 19 | from irc3.plugins.command import command 20 | from requests import RequestException 21 | 22 | from cappuccino import Plugin 23 | 24 | 25 | @irc3.plugin 26 | class CatFacts(Plugin): 27 | requires = ["irc3.plugins.command"] 28 | 29 | def __init__(self, bot): 30 | super().__init__(bot) 31 | self._cache: list[str] = [] 32 | self._limit: int = self.config.get("limit", 1000) 33 | self._max_length: int = self.config.get("max_length", 200) 34 | self._api_url: str = self.config.get("api_url", "https://catfact.ninja/facts") 35 | 36 | def _get_cat_fact(self) -> str: 37 | if not self._cache: 38 | self.logger.debug("Fetching cat facts.") 39 | request_parameters = {"limit": self._limit} 40 | if self._max_length > 0: 41 | request_parameters.update({"max_length": self._max_length}) 42 | 43 | with self.requests.get( 44 | self._api_url, params=request_parameters 45 | ) as response: 46 | self._cache = [fact["fact"] for fact in response.json()["data"]] 47 | random.shuffle(self._cache) 48 | self.logger.debug(f"Loaded {len(self._cache)} facts.") 49 | 50 | return self._cache.pop() 51 | 52 | @command(permission="view") 53 | def catfact(self, mask, target, args): 54 | """Grab a random cat fact. 55 | 56 | %%catfact 57 | """ 58 | 59 | try: 60 | yield self._get_cat_fact() 61 | except RequestException: 62 | yield "Something went horribly wrong while I was researching cat facts. :(" 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cappuccino" 3 | description = "Just another IRC bot." 4 | authors = [{ name = "Lee Watson", email = "me@lwatson.dev" }] 5 | version = "1.20.0" 6 | readme = "README.md" 7 | license = { file = "LICENSE" } 8 | requires-python = ">=3.14,<3.15" 9 | dependencies = [ 10 | "alembic>=1.15.2", 11 | "beautifulsoup4>=4.13.4", 12 | "bottle>=0.13.3", 13 | "html5lib>=1.1", 14 | "httpx[http2]>=0.28.1", 15 | "humanize>=4.12.3", 16 | "influxdb-client>=1.48.0", 17 | "irc3>=1.1.0", 18 | "ircstyle>=0.1.5", 19 | "markovify>=0.9.4", 20 | "psycopg[binary,pool]>=3.2.10", 21 | "pylast>=5.5.0", 22 | "pyyaml>=6.0.2", 23 | "requests>=2.32.3", 24 | "sentry-sdk>=2.27.0", 25 | "sqlalchemy>=2.0.0", 26 | "ujson>=5.10.0", 27 | "urllib3>=2.4.0", 28 | ] 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "pre-commit>=4.0.0", 33 | "pre-commit-hooks>=5.0.0", 34 | "ruff>=0.14.0", 35 | "shellcheck-py>=0.10.0.1", 36 | "yamllint>=1.37.1", 37 | ] 38 | docker = ["jinja2>=3.1.3"] 39 | 40 | [tool.ruff] 41 | force-exclude = true 42 | extend-exclude = ["docker/data/", "docker/config/"] 43 | 44 | [tool.ruff.format] 45 | line-ending = "lf" 46 | 47 | [tool.ruff.lint] 48 | select = [ 49 | "A", 50 | "ARG", 51 | "B", 52 | "BLE", 53 | "C4", 54 | "C90", 55 | "COM", 56 | "DTZ", 57 | "E", 58 | "ERA", 59 | "F", 60 | "FBT", 61 | "FIX", 62 | "FLY", 63 | "FURB", 64 | "G010", 65 | "G101", 66 | "G201", 67 | "G202", 68 | "I", 69 | "ICN", 70 | "INP", 71 | "ISC", 72 | "LOG", 73 | "N", 74 | "PERF", 75 | "PIE", 76 | "PL", 77 | "PT", 78 | "PTH", 79 | "PYI", 80 | "Q", 81 | "RET", 82 | "RSE", 83 | "RUF", 84 | "S", 85 | "SIM", 86 | "SLF", 87 | "SLOT", 88 | "T10", 89 | "T20", 90 | "TC", 91 | "TD", 92 | "TID", 93 | "TRY", 94 | "UP", 95 | "W", 96 | "YTT", 97 | ] 98 | 99 | # ruff format compatibility 100 | ignore = [ 101 | "COM812", 102 | "COM819", 103 | "D206", 104 | "D300", 105 | "E111", 106 | "E114", 107 | "E117", 108 | "E501", 109 | "Q000", 110 | "Q001", 111 | "Q002", 112 | "Q003", 113 | "W191", 114 | ] 115 | 116 | extend-ignore = ["ARG002", "PLR0912", "PLR0915", "PLW2901", "RUF012", "TRY003"] 117 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/env.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | from logging.config import fileConfig 4 | 5 | from sqlalchemy import engine_from_config, pool 6 | 7 | from alembic import context 8 | from cappuccino.db.models import BaseModel 9 | from cappuccino.db.models.ai import AIChannel, CorpusLine # noqa: F401 10 | from cappuccino.db.models.triggers import Trigger # noqa: F401 11 | from cappuccino.db.models.userdb import RiceDB # noqa: F401 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | fileConfig(config.config_file_name) 20 | 21 | # add your model's MetaData object here 22 | target_metadata = BaseModel.metadata 23 | 24 | 25 | def run_migrations_offline(): 26 | """Run migrations in 'offline' mode. 27 | 28 | This configures the context with just a URL 29 | and not an Engine, though an Engine is acceptable 30 | here as well. By skipping the Engine creation 31 | we don't even need a DBAPI to be available. 32 | 33 | Calls to context.execute() here emit the given string to the 34 | script output. 35 | 36 | """ 37 | url = config.get_main_option("sqlalchemy.url") 38 | context.configure( 39 | url=url, 40 | target_metadata=target_metadata, 41 | literal_binds=True, 42 | dialect_opts={"paramstyle": "named"}, 43 | ) 44 | 45 | with context.begin_transaction(): 46 | context.run_migrations() 47 | 48 | 49 | def run_migrations_online(): 50 | """Run migrations in 'online' mode. 51 | 52 | In this scenario we need to create an Engine 53 | and associate a connection with the context. 54 | 55 | """ 56 | bot_config = configparser.ConfigParser() 57 | bot_config.read(os.getenv("SETTINGS_FILE", "config.ini")) 58 | alembic_config = config.get_section(config.config_ini_section) 59 | alembic_config["sqlalchemy.url"] = bot_config["database"]["uri"] 60 | connectable = engine_from_config( 61 | alembic_config, 62 | prefix="sqlalchemy.", 63 | poolclass=pool.NullPool, 64 | ) 65 | 66 | with connectable.connect() as connection: 67 | context.configure(connection=connection, target_metadata=target_metadata) 68 | 69 | with context.begin_transaction(): 70 | context.run_migrations() 71 | 72 | 73 | if context.is_offline_mode(): 74 | run_migrations_offline() 75 | else: 76 | run_migrations_online() 77 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = cappuccino/db/alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = driver://user:pass@localhost/dbname 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | hooks=ruff-check,ruff-format 53 | 54 | ruff-check.type = exec 55 | ruff-check.executable = ruff 56 | ruff-check.options = check --fix REVISION_SCRIPT_FILENAME 57 | 58 | ruff-format.type = exec 59 | ruff-format.executable = ruff 60 | ruff-format.options = format REVISION_SCRIPT_FILENAME 61 | 62 | # Logging configuration 63 | [loggers] 64 | keys = root,sqlalchemy,alembic 65 | 66 | [handlers] 67 | keys = console 68 | 69 | [formatters] 70 | keys = generic 71 | 72 | [logger_root] 73 | level = WARN 74 | handlers = console 75 | qualname = 76 | 77 | [logger_sqlalchemy] 78 | level = WARN 79 | handlers = 80 | qualname = sqlalchemy.engine 81 | 82 | [logger_alembic] 83 | level = INFO 84 | handlers = 85 | qualname = alembic 86 | 87 | [handler_console] 88 | class = StreamHandler 89 | args = (sys.stderr,) 90 | level = NOTSET 91 | formatter = generic 92 | 93 | [formatter_generic] 94 | format = %(levelname)-5.5s [%(name)s] %(message)s 95 | datefmt = %H:%M:%S 96 | -------------------------------------------------------------------------------- /cappuccino/seen.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | from datetime import UTC, datetime 17 | 18 | import irc3 19 | from humanize import naturaltime 20 | from irc3.plugins.command import command 21 | 22 | from cappuccino import Plugin 23 | 24 | _DB_KEY = "last_seen" 25 | 26 | 27 | @irc3.plugin 28 | class Seen(Plugin): 29 | requires = ["irc3.plugins.command", "cappuccino.userdb"] 30 | 31 | def _get_last_seen(self, nick: str) -> datetime: 32 | return self.bot.get_user_value(nick, _DB_KEY) 33 | 34 | def _set_last_seen(self, nick: str, timestamp: datetime): 35 | self.bot.set_user_value(nick, _DB_KEY, timestamp) 36 | 37 | @command(permission="view", aliases=["died"]) 38 | def seen(self, mask, target, args): 39 | """Check when a user was last seen active in any channel. 40 | 41 | %%seen 42 | """ 43 | 44 | nick = args[""] 45 | 46 | if nick.lower() == self.bot.nick.lower(): 47 | return "I'm right here, idiot. -_-" 48 | 49 | if nick.lower() == mask.nick.lower(): 50 | return "Are you seriously asking me that?" 51 | 52 | if not self.bot.get_user_value(nick, _DB_KEY): 53 | return f"I haven't seen any activity from {nick} yet." 54 | 55 | last_seen = self._get_last_seen(nick) 56 | time_now = datetime.now(last_seen.tzinfo) 57 | duration = naturaltime(time_now - last_seen) 58 | full_date = last_seen.strftime("%b %d %Y %H:%M %Z") 59 | 60 | if nick == "kori": 61 | return f"{nick} was right there {duration}. ({full_date})" 62 | 63 | return f"{nick} was last seen {duration}. ({full_date})" 64 | 65 | @irc3.event(irc3.rfc.PRIVMSG) 66 | def on_privmsg(self, target, event, mask, data): 67 | if ( 68 | event == "NOTICE" 69 | or data.startswith("\x01VERSION") 70 | or not target.is_channel 71 | or mask.nick == self.bot.nick 72 | ): 73 | return 74 | 75 | self._set_last_seen(mask.nick, datetime.now(UTC)) 76 | -------------------------------------------------------------------------------- /cappuccino/execshell.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import secrets 17 | import subprocess 18 | 19 | import irc3 20 | import requests 21 | from irc3.plugins.command import command 22 | 23 | from cappuccino import Plugin 24 | 25 | 26 | def _is_multiline_string(text: str): 27 | # require minimum 2 newlines to account for the newline at the end of output. 28 | return text.count("\n") > 1 29 | 30 | 31 | def _exec_wrapper(cmd: dict, input_data: str | None = None) -> str: 32 | if input_data: 33 | input_data = input_data.encode("UTF-8") 34 | 35 | proc = subprocess.run( # noqa: S603 36 | cmd, 37 | input=input_data, 38 | stdout=subprocess.PIPE, 39 | stderr=subprocess.STDOUT, 40 | timeout=5, 41 | check=False, 42 | ) 43 | return proc.stdout.decode("UTF-8").strip() 44 | 45 | 46 | @irc3.plugin 47 | class ExecShell(Plugin): 48 | requires = ["irc3.plugins.command"] 49 | 50 | @command( 51 | permission="admin", show_in_help_list=False, options_first=True, use_shlex=True 52 | ) 53 | def exec(self, mask, target, args): 54 | """Run a system command and upload the output to 0x0.st. 55 | 56 | %%exec ... 57 | """ 58 | 59 | try: 60 | output = _exec_wrapper(args[""]) 61 | if not output: 62 | return f"{mask.nick}: Command returned no output." 63 | 64 | # Don't paste single line outputs. 65 | if not _is_multiline_string(output): 66 | return f"{mask.nick}: {output}" 67 | 68 | # Upload multiline output to 0x0.st to avoid flooding channels. 69 | result = self.requests.post( 70 | "https://0x0.st", 71 | files={"file": (f"cappuccino-{secrets.token_hex()}.txt", output)}, 72 | expires=1, 73 | ) 74 | 75 | except ( 76 | FileNotFoundError, 77 | requests.RequestException, 78 | subprocess.TimeoutExpired, 79 | ) as ex: 80 | return f"{mask.nick}: {ex}" 81 | 82 | return f"{mask.nick}: {result.text}" 83 | -------------------------------------------------------------------------------- /config.dist.ini: -------------------------------------------------------------------------------- 1 | [bot] 2 | nick = cappuccino_test 3 | username = cappuccino_test 4 | realname = cappuccino_test 5 | # Server password, not nickserv 6 | password = 7 | 8 | host = irc.rizon.net 9 | port = 6697 10 | 11 | # uncomment this if you want ssl support 12 | ssl = true 13 | # uncomment this if you don't want to check the certificate 14 | # ssl_verify = CERT_NONE 15 | 16 | # uncomment this if you want to use sasl authentication 17 | # sasl_username = cappuccino_test 18 | # sasl_password = yourpassword 19 | 20 | includes = 21 | cappuccino.nickserv 22 | cappuccino.botui 23 | cappuccino.rice 24 | cappuccino.lastfm 25 | cappuccino.fun 26 | cappuccino.catfacts 27 | cappuccino.sed 28 | cappuccino.ai 29 | cappuccino.seen 30 | cappuccino.execshell 31 | cappuccino.triggers 32 | cappuccino.chanlog 33 | cappuccino.urlinfo 34 | irc3.plugins.ctcp 35 | 36 | # the bot will join #null 37 | # ${hash} is replaced by the # char 38 | autojoins = 39 | ${hash}null 40 | 41 | # Autojoin delay, disabled by default 42 | # float or int value 43 | # autojoin_delay = 3.1 44 | 45 | # The maximum amount of lines irc3 sends at once. 46 | # Default to 4, set to 0 to disable 47 | # flood_burst = 10 48 | 49 | # The number of lines per $flood_rate_delay seconds irc3 sends after reaching 50 | # the $flood_burst limit. 51 | # Default to 1 52 | # flood_rate = 2 53 | 54 | # The bot will send $flood_rate messages per $flood_rate_delay seconds 55 | # Default to 1 56 | # flood_rate_delay = 5 57 | 58 | [ctcp] 59 | version = cappuccino (https://github.com/FoxDev/cappuccino) 60 | 61 | [cappuccino.nickserv] 62 | password = 63 | 64 | [cappuccino.lastfm] 65 | api_key = 66 | 67 | [cappuccino.urlinfo] 68 | ignore_nicks = Nick1 Nick2 69 | ignore_hostnames = example.com 70 | fake_useragent_hostnames = 71 | twitter.com 72 | t.co 73 | 74 | [database] 75 | # https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls 76 | # Only postgresql is officially supported, you're on your own for anything else. 77 | uri = 78 | 79 | [irc3.plugins.command] 80 | # set command char 81 | cmd = . 82 | 83 | antiflood = true 84 | 85 | # set guard policy 86 | guard = irc3.plugins.command.mask_based_policy 87 | 88 | [irc3.plugins.command.masks] 89 | # this section is used by the guard to secure the bot's command 90 | # change your nickname and uncomment the line below 91 | # user!*@* = all_permissions 92 | * = view 93 | 94 | # https://sentry.io 95 | [cappuccino.sentry] 96 | dsn = 97 | 98 | [cappuccino.userdb] 99 | enable_http_server = false 100 | http_host = 127.0.0.1 101 | http_port = 8080 102 | 103 | [cappuccino.influx] 104 | url = http://localhost:8086 105 | org = 106 | bucket = 107 | token = 108 | 109 | # [cappuccino.uptimekuma] 110 | # webhook = "https://my.uptime.kuma" 111 | # interval = 30 112 | -------------------------------------------------------------------------------- /cappuccino/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import logging 17 | import os 18 | from secrets import randbelow 19 | from typing import TYPE_CHECKING 20 | 21 | import requests 22 | from requests import Session 23 | from requests.cookies import RequestsCookieJar 24 | from sqlalchemy import create_engine 25 | from sqlalchemy.orm import sessionmaker 26 | 27 | from cappuccino.util import meta 28 | 29 | if TYPE_CHECKING: 30 | from irc3 import IrcBot 31 | 32 | 33 | def _create_requests_session() -> Session: 34 | requests.packages.urllib3.disable_warnings() 35 | 36 | # Accept YouTube consent cookies automatically 37 | cookie_jar = RequestsCookieJar() 38 | cookie_value = f"YES+srp.gws-20210512-0-RC3.en+FX+{1 + randbelow(1000)}" 39 | cookie_jar.set("CONSENT", cookie_value) 40 | 41 | session = Session() 42 | session.cookies = cookie_jar 43 | session.headers.update( 44 | { 45 | "User-Agent": f"cappuccino {meta.VERSION} - {meta.SOURCE}", 46 | "Accept-Language": "en-GB,en-US,en;q=0.5", 47 | "timeout": "5", 48 | "allow_redirects": "true", 49 | } 50 | ) 51 | return session 52 | 53 | 54 | class Plugin: 55 | def __init__(self, bot: IrcBot): 56 | plugin_module = self.__class__.__module__ 57 | self.bot = bot 58 | self.config: dict = self.bot.config.get(plugin_module, {}) 59 | self.logger = logging.getLogger(f"irc3.{plugin_module}") 60 | self.requests = _create_requests_session() 61 | 62 | db_config = self.bot.config.get("database", {}) 63 | db = create_engine( 64 | db_config.get("uri"), 65 | pool_size=db_config.get("pool_size", os.cpu_count()), 66 | max_overflow=db_config.get("max_overflow", os.cpu_count()), 67 | ) 68 | self.db_session = sessionmaker(db) 69 | 70 | if self.config: 71 | # I have no idea where these are coming from but whatever. 72 | weird_keys = ["#", "hash"] 73 | for key in weird_keys: 74 | if key in self.config: 75 | self.config.pop(key) 76 | 77 | self.logger.debug(f"Configuration for {plugin_module}: {self.config}") 78 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/c7308e3c814a_seperate_ricedb_json_into_columns.py: -------------------------------------------------------------------------------- 1 | """Seperate ricedb json into columns 2 | 3 | Revision ID: c7308e3c814a 4 | Revises: ca58ba59b328 5 | Create Date: 2020-02-02 20:34:18.585110 6 | 7 | """ 8 | 9 | from datetime import UTC, datetime 10 | 11 | import sqlalchemy as sa 12 | from sqlalchemy import select, update 13 | 14 | from alembic import op 15 | from cappuccino.userdb import RiceDB 16 | 17 | # revision identifiers, used by Alembic. 18 | revision = "c7308e3c814a" 19 | down_revision = "ca58ba59b328" 20 | branch_labels = None 21 | depends_on = None 22 | 23 | table = "ricedb" 24 | 25 | 26 | def upgrade(): 27 | op.add_column(table, sa.Column("dtops", sa.JSON(), nullable=True)) 28 | op.add_column(table, sa.Column("homescreens", sa.JSON(), nullable=True)) 29 | op.add_column(table, sa.Column("stations", sa.JSON(), nullable=True)) 30 | op.add_column(table, sa.Column("pets", sa.JSON(), nullable=True)) 31 | op.add_column(table, sa.Column("dotfiles", sa.JSON(), nullable=True)) 32 | op.add_column(table, sa.Column("handwritings", sa.JSON(), nullable=True)) 33 | op.add_column(table, sa.Column("distros", sa.JSON(), nullable=True)) 34 | op.add_column(table, sa.Column("websites", sa.JSON(), nullable=True)) 35 | op.add_column(table, sa.Column("selfies", sa.JSON(), nullable=True)) 36 | op.add_column(table, sa.Column("lastfm", sa.String(), nullable=True)) 37 | op.add_column(table, sa.Column("last_seen", sa.DateTime(), nullable=True)) 38 | 39 | copy_json_to_columns() 40 | 41 | 42 | def copy_json_to_columns(): 43 | ricedb_table = RiceDB.__table__ 44 | conn = op.get_bind() 45 | for result in conn.execute(select(ricedb_table)): 46 | user = result[0] 47 | json_data = result[1] 48 | last_seen = json_data.get("last_seen") 49 | if last_seen: 50 | last_seen = datetime.fromtimestamp(last_seen, tz=UTC) 51 | 52 | values = { 53 | # or None because empty lists aren't considered SQL NULL 54 | "dtops": json_data.get("dtops") or None, 55 | "homescreens": json_data.get("homescreens") or None, 56 | "stations": json_data.get("stations") or None, 57 | "pets": json_data.get("pets") or None, 58 | "dotfiles": json_data.get("dotfiles") or None, 59 | "handwritings": json_data.get("handwritings") or None, 60 | "distros": json_data.get("distros") or None, 61 | "websites": json_data.get("websites") or None, 62 | "selfies": json_data.get("selfies") or None, 63 | "lastfm": json_data.get("lastfm") or None, 64 | "last_seen": last_seen or None, 65 | } 66 | 67 | conn.execute( 68 | update(ricedb_table).values(values).where(ricedb_table.c.nick == user) 69 | ) 70 | 71 | 72 | def downgrade(): 73 | op.drop_column(table, "dtops") 74 | op.drop_column(table, "homescreens") 75 | op.drop_column(table, "stations") 76 | op.drop_column(table, "pets") 77 | op.drop_column(table, "dotfiles") 78 | op.drop_column(table, "handwritings") 79 | op.drop_column(table, "distros") 80 | op.drop_column(table, "websites") 81 | op.drop_column(table, "selfies") 82 | op.drop_column(table, "lastfm") 83 | op.drop_column(table, "last_seen") 84 | -------------------------------------------------------------------------------- /cappuccino/db/alembic/versions/15a2d5ea1971_switch_all_string_types_to_text.py: -------------------------------------------------------------------------------- 1 | """Switch all string types to text 2 | 3 | Revision ID: 15a2d5ea1971 4 | Revises: 3668c91fa898 5 | Create Date: 2025-05-06 17:46:18.815376 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "15a2d5ea1971" 15 | down_revision = "3668c91fa898" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.alter_column( 23 | "ai_channels", 24 | "name", 25 | existing_type=sa.VARCHAR(), 26 | type_=sa.Text(), 27 | existing_nullable=False, 28 | ) 29 | op.alter_column( 30 | "ai_corpus", 31 | "line", 32 | existing_type=sa.VARCHAR(), 33 | type_=sa.Text(), 34 | existing_nullable=False, 35 | ) 36 | op.alter_column( 37 | "ai_corpus", 38 | "channel_name", 39 | existing_type=sa.VARCHAR(), 40 | type_=sa.Text(), 41 | existing_nullable=False, 42 | ) 43 | op.alter_column( 44 | "ricedb", 45 | "nick", 46 | existing_type=sa.VARCHAR(), 47 | type_=sa.Text(), 48 | existing_nullable=False, 49 | ) 50 | op.alter_column( 51 | "ricedb", 52 | "lastfm", 53 | existing_type=sa.VARCHAR(), 54 | type_=sa.Text(), 55 | existing_nullable=True, 56 | ) 57 | op.alter_column( 58 | "triggers", 59 | "name", 60 | existing_type=sa.VARCHAR(), 61 | type_=sa.Text(), 62 | existing_nullable=False, 63 | ) 64 | op.alter_column( 65 | "triggers", 66 | "channel", 67 | existing_type=sa.VARCHAR(), 68 | type_=sa.Text(), 69 | existing_nullable=False, 70 | ) 71 | op.alter_column( 72 | "triggers", 73 | "response", 74 | existing_type=sa.VARCHAR(), 75 | type_=sa.Text(), 76 | existing_nullable=False, 77 | ) 78 | # ### end Alembic commands ### 79 | 80 | 81 | def downgrade(): 82 | # ### commands auto generated by Alembic - please adjust! ### 83 | op.alter_column( 84 | "triggers", 85 | "response", 86 | existing_type=sa.Text(), 87 | type_=sa.VARCHAR(), 88 | existing_nullable=False, 89 | ) 90 | op.alter_column( 91 | "triggers", 92 | "channel", 93 | existing_type=sa.Text(), 94 | type_=sa.VARCHAR(), 95 | existing_nullable=False, 96 | ) 97 | op.alter_column( 98 | "triggers", 99 | "name", 100 | existing_type=sa.Text(), 101 | type_=sa.VARCHAR(), 102 | existing_nullable=False, 103 | ) 104 | op.alter_column( 105 | "ricedb", 106 | "lastfm", 107 | existing_type=sa.Text(), 108 | type_=sa.VARCHAR(), 109 | existing_nullable=True, 110 | ) 111 | op.alter_column( 112 | "ricedb", 113 | "nick", 114 | existing_type=sa.Text(), 115 | type_=sa.VARCHAR(), 116 | existing_nullable=False, 117 | ) 118 | op.alter_column( 119 | "ai_corpus", 120 | "channel_name", 121 | existing_type=sa.Text(), 122 | type_=sa.VARCHAR(), 123 | existing_nullable=False, 124 | ) 125 | op.alter_column( 126 | "ai_corpus", 127 | "line", 128 | existing_type=sa.Text(), 129 | type_=sa.VARCHAR(), 130 | existing_nullable=False, 131 | ) 132 | op.alter_column( 133 | "ai_channels", 134 | "name", 135 | existing_type=sa.Text(), 136 | type_=sa.VARCHAR(), 137 | existing_nullable=False, 138 | ) 139 | # ### end Alembic commands ### 140 | -------------------------------------------------------------------------------- /cappuccino/botui.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import irc3 17 | from irc3.plugins.command import command 18 | 19 | from cappuccino import Plugin 20 | from cappuccino.util import meta 21 | 22 | 23 | @irc3.plugin 24 | class BotUI(Plugin): 25 | requires = ["irc3.plugins.command", "irc3.plugins.userlist"] 26 | 27 | @command(permission="view", aliases=["source", "version"]) 28 | def bots(self, mask, target, args): 29 | """Report in! 30 | 31 | %%bots 32 | """ 33 | yield f"Reporting in! [cappuccino {meta.FULL_VERSION}] - {meta.SOURCE}" 34 | 35 | @command(permission="admin", show_in_help_list=False) 36 | def join(self, mask, target, args): 37 | """Join a channel. 38 | 39 | %%join [] 40 | """ 41 | 42 | channel = args[""] 43 | if args[""]: 44 | channel += f" {args['']}" 45 | 46 | self.bot.join(channel) 47 | self.logger.info(f"Joined {channel}") 48 | 49 | @command(permission="admin", show_in_help_list=False) 50 | def part(self, mask, target, args): 51 | """Leave a channel. 52 | 53 | %%part [] 54 | """ 55 | 56 | if args[""]: 57 | target = args[""] 58 | 59 | self.bot.part(target) 60 | self.logger.info(f"Parted {target}") 61 | 62 | @command(permission="admin", show_in_help_list=False) 63 | def quit(self, mask, target, args): 64 | """Shut the bot down. 65 | 66 | %%quit 67 | """ 68 | 69 | self.logger.info(f"Shutting down as requested by {mask}") 70 | self.bot.quit() 71 | 72 | @command(permission="admin", show_in_help_list=False) 73 | def nick(self, mask, target, args): 74 | """Change nickname of the bot. 75 | 76 | %%nick 77 | """ 78 | 79 | nick = args[""] 80 | self.bot.set_nick(nick) 81 | self.logger.info(f"Changed nick to {nick}") 82 | 83 | @command(permission="admin", show_in_help_list=False) 84 | def mode(self, mask, target, args): 85 | """Set user mode for the bot. 86 | 87 | %%mode 88 | """ 89 | 90 | self.bot.mode(self.bot.nick, args[""]) 91 | 92 | @command(permission="admin", show_in_help_list=False) 93 | def msg(self, mask, target, args): 94 | """Send a message. 95 | 96 | %%msg ... 97 | """ 98 | 99 | msg = " ".join(args[""] or []) 100 | self.bot.privmsg(args[""], msg) 101 | 102 | @command(permission="admin", aliases=["bc", "broadcast"], show_in_help_list=False) 103 | def psa(self, mask, target, args): 104 | """Broadcast a message to all channels. 105 | 106 | %%psa ... 107 | """ 108 | message = " ".join(args[""]) 109 | for channel in self.bot.channels: 110 | self.bot.privmsg(channel, f"[PSA] {message}") 111 | self.logger.info(f"Sent PSA requested by {mask}: {message}") 112 | 113 | @command(permission="view") 114 | def ping(self, mask, target, args): 115 | """Ping! 116 | 117 | %%ping 118 | """ 119 | self.bot.privmsg(target, f"{mask.nick}: Pong!") 120 | -------------------------------------------------------------------------------- /cappuccino/userdb.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import threading 17 | 18 | import bottle 19 | from sqlalchemy import ( 20 | desc, 21 | func, 22 | inspect, 23 | nullslast, 24 | select, 25 | update, 26 | ) 27 | 28 | from cappuccino import Plugin 29 | from cappuccino.db.models.userdb import RiceDB 30 | from cappuccino.util.formatting import unstyle 31 | 32 | try: 33 | import ujson as json 34 | except ImportError: 35 | import json 36 | 37 | import contextlib 38 | 39 | import irc3 40 | 41 | 42 | def _strip_path(): 43 | bottle.request.environ["PATH_INFO"] = bottle.request.environ["PATH_INFO"].rstrip( 44 | "/" 45 | ) 46 | 47 | 48 | @irc3.plugin 49 | class UserDB(Plugin): 50 | def __init__(self, bot): 51 | super().__init__(bot) 52 | 53 | if self.config.get("enable_http_server", False): 54 | host = self.config.get("http_host", "127.0.0.1") 55 | port = int(self.config.get("http_port", 8080)) 56 | bottle.hook("before_request")(_strip_path) 57 | bottle.route("/")(self._json_dump) 58 | bottle_thread = threading.Thread( 59 | target=bottle.run, 60 | kwargs={"quiet": True, "host": host, "port": port}, 61 | name=f"{__name__} HTTP server", 62 | daemon=True, 63 | ) 64 | bottle_thread.start() 65 | 66 | @irc3.extend 67 | def get_user_value(self, username: str, key: str): 68 | with self.db_session() as session: 69 | return session.scalar( 70 | select(RiceDB.__table__.columns[key]).where( 71 | func.lower(RiceDB.nick) == username.lower() 72 | ) 73 | ) 74 | 75 | @irc3.extend 76 | def del_user_value(self, username: str, key: str): 77 | self.set_user_value(username, key, None) 78 | 79 | @irc3.extend 80 | def set_user_value(self, username: str, key: str, value=None): 81 | with self.db_session() as session: 82 | user = session.scalar( 83 | update(RiceDB) 84 | .returning(RiceDB) 85 | .where(func.lower(RiceDB.nick) == username.lower()) 86 | .values({key: value}) 87 | ) 88 | 89 | if user is None: 90 | user = RiceDB(nick=username, **{key: value}) 91 | session.add(user) 92 | 93 | def _json_dump(self): 94 | bottle.response.content_type = "application/json" 95 | 96 | data = [] 97 | with self.db_session() as session: 98 | all_users = session.scalars( 99 | select(RiceDB).order_by(nullslast(desc(RiceDB.last_seen))) 100 | ).all() 101 | 102 | for user in all_users: 103 | user_dict = {} 104 | for column, value in inspect(user).attrs.items(): 105 | value = value.value 106 | if value is None: 107 | continue 108 | 109 | if column == "last_seen": 110 | value = value.timestamp() 111 | 112 | with contextlib.suppress(TypeError, AttributeError): 113 | value = ( 114 | [unstyle(val) for val in value] 115 | if isinstance(value, list) 116 | else unstyle(value) 117 | ) 118 | 119 | user_dict[column] = value 120 | data.append(user_dict) 121 | return json.dumps(data) 122 | -------------------------------------------------------------------------------- /cappuccino/sed.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import re 17 | import subprocess 18 | from collections import deque 19 | 20 | import irc3 21 | 22 | from cappuccino import Plugin 23 | from cappuccino.util.formatting import Color, style 24 | 25 | _SED_PRIVMSG = r"\s*s[/|\\!\.,\\].+" 26 | _SED_CHECKER = re.compile("^" + _SED_PRIVMSG) 27 | 28 | 29 | def _sed_wrapper(text: str, command: str) -> str: 30 | # Must be GNU sed 31 | arguments = ["sed", "--sandbox", "--regexp-extended", command] 32 | sed_input = text.strip().encode("UTF-8") 33 | sed = subprocess.run( # noqa: S603 34 | arguments, 35 | stdout=subprocess.PIPE, 36 | stderr=subprocess.STDOUT, 37 | input=sed_input, 38 | check=False, 39 | ) 40 | stream = sed.stdout.decode("UTF-8").strip() 41 | 42 | if sed.returncode != 0: 43 | if not stream: 44 | return "Unknown sed error." 45 | raise EditorError(stream.replace("sed: -e ", "")) 46 | 47 | return stream 48 | 49 | 50 | def _edit(text: str, command: str) -> str: 51 | output = _sed_wrapper(text, command) 52 | if not output or output == text: 53 | return text 54 | return output 55 | 56 | 57 | class EditorError(Exception): 58 | """An error occurred while processing the editor command.""" 59 | 60 | 61 | @irc3.plugin 62 | class Sed(Plugin): 63 | def __init__(self, bot): 64 | super().__init__(bot) 65 | self._history_buffer: dict[str, deque[tuple[str, str]]] = {} 66 | 67 | @irc3.event(irc3.rfc.PRIVMSG) 68 | def update_chat_history(self, target, event, mask, data): 69 | if ( 70 | event != "PRIVMSG" 71 | or _SED_CHECKER.match(data) 72 | or data.startswith(self.bot.config.cmd) 73 | ): 74 | return 75 | 76 | # Strip ACTION data and just use the message. 77 | data = data.replace("\x01ACTION ", "").replace("\x01", "") 78 | line = (mask.nick, data) 79 | 80 | if target in self._history_buffer: 81 | self._history_buffer[target].append(line) 82 | return 83 | 84 | queue = deque(maxlen=25) 85 | queue.append(line) 86 | self._history_buffer.update({target: queue}) 87 | 88 | @irc3.event( 89 | rf":(?P\S+!\S+@\S+) PRIVMSG (?P\S+) :(?P{_SED_PRIVMSG})" 90 | ) 91 | def sed(self, mask, target, command): 92 | if target not in self._history_buffer: 93 | return 94 | 95 | for target_user, message in reversed(self._history_buffer[target]): 96 | message = message.strip() 97 | try: 98 | new_message = _edit(message, command) 99 | except EditorError as error: 100 | self.bot.notice(mask.nick, str(error)) 101 | # Don't even check the rest if the sed command is invalid. 102 | return 103 | 104 | if not new_message or new_message == message: 105 | continue 106 | 107 | # Prevent spam. 108 | max_extra_chars = 32 109 | max_len = len(message) + max_extra_chars 110 | max_len_hard = 256 111 | error_msg = ( 112 | "Replacement would be too long." 113 | " I won't post it to prevent potential spam." 114 | ) 115 | if ( 116 | len(new_message) > len(error_msg) and len(new_message) > max_len 117 | ) or len(new_message) > max_len_hard: 118 | self.bot.notice(mask.nick, style(error_msg, fg=Color.RED)) 119 | return 120 | 121 | emphasised_meant = style("meant", bold=True) 122 | if mask.nick == target_user: 123 | if target.is_channel: 124 | prefix = f"{mask.nick} {emphasised_meant} to say:" 125 | else: 126 | self.bot.privmsg(mask.nick, new_message) 127 | return 128 | else: 129 | prefix = f"{mask.nick} thinks {target_user} {emphasised_meant} to say:" 130 | self.bot.privmsg(target, f"{prefix} {new_message}") 131 | return 132 | -------------------------------------------------------------------------------- /cappuccino/influx.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | from datetime import UTC, datetime 17 | from typing import TYPE_CHECKING 18 | 19 | import irc3 20 | from influxdb_client import InfluxDBClient, Point 21 | from irc3 import rfc 22 | 23 | from cappuccino import Plugin 24 | 25 | if TYPE_CHECKING: 26 | from irc3.utils import IrcString 27 | 28 | 29 | @irc3.plugin 30 | class Influx(Plugin): 31 | requires = ["irc3.plugins.userlist"] 32 | 33 | def __init__(self, bot): 34 | super().__init__(bot) 35 | self._url = self.config.get("url") 36 | self._org = self.config.get("org") 37 | self._bucket = self.config.get("bucket") 38 | self._token = self.config.get("token") 39 | 40 | if not all((self._url, self._org, self._bucket, self._token)): 41 | self.logger.error("InfluxDB requires *all* config keys to be set.") 42 | return 43 | 44 | self._client = InfluxDBClient(url=self._url, token=self._token, org=self._org) 45 | 46 | def _record_event( 47 | self, 48 | event: str, 49 | data: str | None = None, 50 | user: IrcString = None, 51 | channel: IrcString = None, 52 | target: str | None = None, 53 | ): 54 | if not channel or not channel.is_channel or not user or user.is_server: 55 | return 56 | 57 | if user.is_user: 58 | user = user.nick 59 | 60 | data = data.replace("\x00", "") if data else "" 61 | 62 | with self._client.write_api() as write_api: 63 | point = ( 64 | Point("channel_activity") 65 | .tag("channel", channel) 66 | .tag("target", target) 67 | .tag("user", user) 68 | .tag("event", event) 69 | .field("data", data or "") 70 | .time(datetime.now(UTC)) 71 | ) 72 | write_api.write(bucket=self._bucket, org=self._org, record=point) 73 | 74 | def _record_user_count(self, channel): 75 | with self._client.write_api() as write_api: 76 | point = ( 77 | Point("channel_members") 78 | .tag("channel", channel) 79 | .field("user_count", len(self.bot.channels.get(channel))) 80 | .time(datetime.now(UTC)) 81 | ) 82 | write_api.write(bucket=self._bucket, org=self._org, record=point) 83 | 84 | @irc3.event(rfc.PRIVMSG) 85 | @irc3.event(rfc.PRIVMSG, iotype="out") 86 | def on_privmsg(self, mask=None, event=None, target=None, data=None): 87 | if event == "NOTICE" or data.startswith("\x01VERSION") or not target.is_channel: 88 | return 89 | 90 | self._record_event(event, user=mask, data=data, channel=target) 91 | 92 | @irc3.event(rfc.JOIN_PART_QUIT) 93 | @irc3.event(rfc.JOIN_PART_QUIT, iotype="out") 94 | def on_join_part_quit(self, mask=None, event=None, channel=None, data=None): 95 | self._record_event(event, user=mask, data=data, channel=channel) 96 | if event in ("QUIT", "PART") and mask.nick == self.bot.nick: 97 | return 98 | self._record_user_count(channel) 99 | 100 | @irc3.event(rfc.KICK) 101 | @irc3.event(rfc.KICK, iotype="out") 102 | def on_kick(self, mask=None, event=None, channel=None, target=None, data=None): 103 | if data == mask: 104 | data = None 105 | 106 | self._record_event(event, data=data, user=mask, channel=channel, target=target) 107 | self._record_user_count(channel) 108 | 109 | def on_kick_out(self, *args, **kwargs): 110 | yield self.on_kick(*args, **kwargs) 111 | 112 | @irc3.event(rfc.TOPIC) 113 | @irc3.event(rfc.TOPIC, iotype="out") 114 | def on_topic(self, mask=None, channel=None, data=None): 115 | if not mask: 116 | mask = self.bot.nick 117 | 118 | self._record_event("TOPIC", user=mask, data=data, channel=channel) 119 | 120 | def on_topic_out(self, *args, **kwargs): 121 | yield self.on_topic(*args, **kwargs) 122 | 123 | @irc3.event(rfc.MODE) 124 | @irc3.event(rfc.MODE, iotype="out") 125 | def on_mode(self, mask=None, event=None, target=None, modes=None, data=None): 126 | self._record_event(event, data=modes, user=mask, channel=target, target=data) 127 | 128 | def on_mode_out(self, *args, **kwargs): 129 | yield self.on_mode(*args, **kwargs) 130 | 131 | @irc3.event(rfc.RPL_NAMREPLY) 132 | def names(self, channel=None, data=None, **kwargs): 133 | self._record_user_count(channel) 134 | -------------------------------------------------------------------------------- /cappuccino/triggers.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import re 17 | 18 | import irc3 19 | from irc3.plugins.command import command 20 | from sqlalchemy import delete, func, select, update 21 | 22 | from cappuccino import Plugin 23 | from cappuccino.db.models.triggers import Trigger 24 | from cappuccino.util.channel import is_chanop 25 | from cappuccino.util.formatting import Color, style 26 | 27 | 28 | @irc3.plugin 29 | class Triggers(Plugin): 30 | requires = ["irc3.plugins.command", "irc3.plugins.userlist"] 31 | 32 | def __init__(self, bot): 33 | super().__init__(bot) 34 | 35 | def _get_trigger(self, channel: str, name: str): 36 | with self.db_session() as session: 37 | return session.scalar( 38 | select(Trigger.response) 39 | .where(func.lower(Trigger.name) == name.lower()) 40 | .where(func.lower(Trigger.channel) == channel.lower()) 41 | ) 42 | 43 | def _set_trigger(self, channel: str, name: str, response: str): 44 | with self.db_session() as session: 45 | trigger_model = session.scalar( 46 | update(Trigger) 47 | .returning(Trigger) 48 | .where(func.lower(Trigger.name) == name.lower()) 49 | .where(func.lower(Trigger.channel) == channel.lower()) 50 | .values(response=response) 51 | ) 52 | 53 | if trigger_model is None: 54 | trigger_model = Trigger(name=name, channel=channel, response=response) 55 | session.add(trigger_model) 56 | 57 | def _delete_trigger(self, channel: str, name: str) -> bool: 58 | with self.db_session() as session: 59 | trigger_object = session.scalar( 60 | delete(Trigger) 61 | .where(func.lower(Trigger.name) == name.lower()) 62 | .where(func.lower(Trigger.channel) == channel.lower()) 63 | .returning(Trigger) 64 | ) 65 | return trigger_object is not None 66 | 67 | def _get_triggers_list(self, channel: str) -> list: 68 | with self.db_session() as session: 69 | return session.scalars( 70 | select(Trigger.name).where( 71 | func.lower(Trigger.channel) == channel.lower() 72 | ) 73 | ).all() 74 | 75 | @command(permission="view") 76 | def trigger(self, mask, target, args): 77 | """Manages predefined responses to message triggers. 78 | 79 | %%trigger (set ... | del | list) 80 | """ 81 | 82 | if not target.is_channel: 83 | return "This command can only be used in channels." 84 | 85 | if (args["set"] or args["del"]) and not is_chanop(self.bot, target, mask.nick): 86 | return "Only channel operators may modify channel triggers." 87 | 88 | response = None 89 | name = args[""] 90 | 91 | if args["set"]: 92 | self._set_trigger(target, name, " ".join(args[""])) 93 | response = f"Trigger '{name}' set." 94 | elif args["del"]: 95 | response = ( 96 | f"Deleted trigger '{name}'." 97 | if self._delete_trigger(target, name) 98 | else "No such trigger." 99 | ) 100 | elif args["list"]: 101 | trigger_list = self._get_triggers_list(target) 102 | self.logger.debug(trigger_list) 103 | if trigger_list: 104 | trigger_list = ", ".join(trigger_list) 105 | response = f"Available triggers for {target}: {trigger_list}" 106 | else: 107 | response = f"No triggers available for {target}" 108 | 109 | return response 110 | 111 | @irc3.event(irc3.rfc.PRIVMSG) 112 | def on_privmsg(self, target, event, mask, data): 113 | if mask.nick == self.bot.nick or event == "NOTICE": 114 | return 115 | 116 | captured_triggers = re.findall(r"\?([A-Za-z0-9]+)", data) 117 | if not captured_triggers: 118 | return 119 | 120 | triggers = set(captured_triggers[:3]) 121 | responses = [] 122 | for trigger in triggers: 123 | response = self._get_trigger(target, trigger) 124 | trigger = style(trigger.lower(), fg=Color.ORANGE) 125 | if response is not None: 126 | responses.append(f"[{trigger}] {response}") 127 | 128 | for response in responses: 129 | self.bot.privmsg(target, response) 130 | -------------------------------------------------------------------------------- /cappuccino/lastfm.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import irc3 17 | import pylast 18 | from irc3.plugins.command import command 19 | 20 | from cappuccino import Plugin 21 | from cappuccino.util.formatting import style, truncate_with_ellipsis 22 | 23 | _DB_KEY = "lastfm" 24 | _MAX_TRACK_ARTIST_LEN = 32 25 | _MAX_TRACK_TITLE_LEN = 75 26 | 27 | 28 | def _add_lastfm_suffix(irc_username: str, lastfm_username: str) -> str: 29 | """ 30 | Adds a last.fm username as a suffix to the IRC user info if the two are not equal. 31 | """ 32 | 33 | if irc_username.lower() != lastfm_username.lower(): 34 | return f"{irc_username} ({lastfm_username})" 35 | return irc_username 36 | 37 | 38 | @irc3.plugin 39 | class LastFM(Plugin): 40 | requires = ["irc3.plugins.command", "cappuccino.userdb"] 41 | 42 | def __init__(self, bot): 43 | super().__init__(bot) 44 | 45 | api_key = self.config.get("api_key", None) 46 | if not api_key: 47 | self.logger.error("Missing last.fm API key") 48 | return 49 | 50 | self._lastfm = pylast.LastFMNetwork(api_key=api_key) 51 | 52 | def _set_lastfm_username(self, irc_username: str, lastfm_username: str) -> str: 53 | """Verify and set a user's last.fm username.""" 54 | try: 55 | lastfm_username = self._lastfm.get_user(lastfm_username).get_name( 56 | properly_capitalized=True 57 | ) 58 | except pylast.WSError: 59 | return "That user doesn't appear to exist. Are you trying to trick me? :^)" 60 | else: 61 | self.bot.set_user_value(irc_username, _DB_KEY, lastfm_username) 62 | return "Last.fm account linked successfully." 63 | 64 | @command(name="np", permission="view", aliases=["lastfm"]) 65 | def now_playing(self, mask, target, args): 66 | """View currently playing track info. 67 | 68 | %%np [(-s | --set) | ] 69 | """ 70 | 71 | if args["--set"] or args["-s"]: 72 | return self._set_lastfm_username(mask.nick, args[""]) 73 | 74 | base_command = f"{self.bot.config.cmd}np" 75 | irc_target_username = args[""] or mask.nick 76 | lastfm_username = self.bot.get_user_value(irc_target_username, _DB_KEY) 77 | 78 | response = "" 79 | try: 80 | if not lastfm_username: 81 | if irc_target_username == mask.nick: 82 | response = ( 83 | f"You have not linked a Last.fm account." 84 | f" Please do so with {base_command} --set " 85 | ) 86 | else: 87 | response = ( 88 | f"{irc_target_username} has not linked a Last.fm account." 89 | f" Ask them to link one with {base_command} --set " 90 | ) 91 | else: 92 | try: 93 | lastfm_user = self._lastfm.get_user(lastfm_username) 94 | lastfm_username = lastfm_user.get_name(properly_capitalized=True) 95 | except pylast.WSError: 96 | if irc_target_username == mask.nick: 97 | response = ( 98 | f"Your Last.fm account appears to no longer exist." 99 | f" Please link a new one with {base_command} --set " 100 | ) 101 | else: 102 | possessive_nick = ( 103 | f"{irc_target_username}'" 104 | if irc_target_username.endswith("s") 105 | else f"{irc_target_username}'s" 106 | ) 107 | response = ( 108 | f"{possessive_nick} last.fm account appears to no longer exist." 109 | f" Ask them to link a new one with {base_command} --set " 110 | ) 111 | else: 112 | name_tag = _add_lastfm_suffix(irc_target_username, lastfm_username) 113 | current_track = lastfm_user.get_now_playing() 114 | if not current_track: 115 | response = f"{name_tag} is not listening to anything right now." 116 | else: 117 | artist = current_track.get_artist().get_name().strip() 118 | title = current_track.get_title().strip() 119 | artist = truncate_with_ellipsis(artist, _MAX_TRACK_ARTIST_LEN) 120 | title = truncate_with_ellipsis(title, _MAX_TRACK_TITLE_LEN) 121 | artist = style(artist, bold=True) 122 | title = style(title, bold=True) 123 | track_info = f"{title} by {artist}" 124 | response = f"{name_tag} is now playing {track_info}" 125 | except ( 126 | pylast.NetworkError, 127 | pylast.MalformedResponseError, 128 | pylast.WSError, 129 | ) as err: 130 | response = style(err, bold=True) 131 | 132 | return response 133 | -------------------------------------------------------------------------------- /cappuccino/fun.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import random 17 | import re 18 | 19 | import irc3 20 | from irc3.plugins.command import command 21 | from requests import RequestException 22 | 23 | from cappuccino import Plugin 24 | from cappuccino.util.formatting import Color, style 25 | 26 | _RANDOM_CHANCE = 0.33 27 | _DECIDE_DELIMITERS = [" or ", ",", "|"] 28 | # Borrowed from https://github.com/GeneralUnRest/8ball-bot/blob/master/8ball.js 29 | _EIGHTBALL_RESPONSES = [ 30 | "Signs point to yes.", 31 | "Yes.", 32 | "Reply hazy, try again.", 33 | "Without a doubt.", 34 | "My sources say no.", 35 | "As I see it, yes.", 36 | "You may rely on it.", 37 | "Concentrate and ask again.", 38 | "Outlook not so good.", 39 | "It is decidedly so.", 40 | "Better not tell you now.", 41 | "Very doubtful.", 42 | "Yes - definitely.", 43 | "It is certain.", 44 | "Cannot predict now.", 45 | "Most likely.", 46 | "Ask again later.", 47 | "My reply is no.", 48 | "Outlook good.", 49 | "Don't count on it.", 50 | ] 51 | 52 | 53 | @irc3.plugin 54 | class Fun(Plugin): 55 | requires = ["irc3.plugins.command"] 56 | 57 | def _reply(self, target: str, message: str): 58 | # Only reply a certain percentage of the time. AKA rate-limiting. Sort of. 59 | if random.random() <= _RANDOM_CHANCE: # noqa: S311 60 | self.bot.privmsg(target, message) 61 | 62 | @command(permission="view", use_shlex=False) 63 | def decide(self, mask, target, args): 64 | """Make the difficult decisions in life. 65 | 66 | %%decide ... 67 | """ 68 | 69 | options = " ".join(args[""]) 70 | for delimiter in _DECIDE_DELIMITERS: 71 | options = options.replace(delimiter, "|") 72 | options = options.split("|") 73 | options = list( 74 | filter( 75 | None, 76 | { 77 | option.replace(delimiter, "").strip() 78 | for delimiter in _DECIDE_DELIMITERS 79 | for option in options 80 | if option not in _DECIDE_DELIMITERS 81 | }, 82 | ) 83 | ) 84 | 85 | [ 86 | options.remove(delimiter.strip()) 87 | for delimiter in _DECIDE_DELIMITERS 88 | if delimiter.strip() in options 89 | ] 90 | 91 | if not options: 92 | return ( 93 | f"{mask.nick}:" 94 | f" I can't make a decision for you if you don't give me any choices >:V" 95 | ) 96 | 97 | options_length = len(options) 98 | if options_length == 1: 99 | options = ["Yes.", "Maybe.", "No."] 100 | 101 | return f"{mask.nick}: {random.choice(options)}" # noqa: S311 102 | 103 | @command(permission="view", name="8ball") 104 | def eightball(self, mask, target, args): 105 | """Consult the wise and powerful 8 ball. 106 | 107 | %%8ball ... 108 | """ 109 | 110 | return f"{mask.nick}: {random.choice(_EIGHTBALL_RESPONSES)}" # noqa: S311 111 | 112 | @irc3.event( 113 | r'(?i).*PRIVMSG (?P#\S+) :\s*\[+(?P[A-Za-z0-9-_ \'"!]+)\]+$' 114 | ) 115 | def intensify(self, target, data): 116 | max_length = 32 117 | data = data.strip().upper() 118 | if not data.endswith("INTENSIFIES"): 119 | data += " INTENSIFIES" 120 | 121 | if data and len(data) <= max_length: 122 | self.bot.privmsg(target, style(f"[{data}]", bold=True)) 123 | 124 | @irc3.event(r"(?i).*PRIVMSG (?P#\S+) :\s*wew$") 125 | def wew(self, target): 126 | self._reply(target, style("w e w l a d", bold=True)) 127 | 128 | @irc3.event(r"(?i).*PRIVMSG (?P#\S+) :\s*ayy+$") 129 | def ayy(self, target): 130 | self._reply(target, "lmao") 131 | 132 | @irc3.event(r"(?i).*PRIVMSG (?P#\S+) :\s*same$") 133 | def same(self, target): 134 | self._reply(target, style("same", bold=True)) 135 | 136 | @irc3.event(r"(?i).*PRIVMSG (?P\S+) :\s*benis$") 137 | def benis(self, target): 138 | self._reply(target, style("3===D", bold=True)) 139 | 140 | @irc3.event( 141 | r"(?i)^(@(?P\S+) )?:(?P\S+!\S+@\S+) PRIVMSG (?P\S+) :.*homo.*" 142 | ) 143 | def homo(self, target, mask): 144 | self._reply(target, f"hahahaha {mask.nick} said homo xDDD") 145 | 146 | @irc3.event( 147 | r"(?i)^(@(?P\S+) )?:(?P\S+!\S+@\S+) PRIVMSG (?P\S+) :.*loli.*" 148 | ) 149 | def loli(self, target, mask): 150 | link = style("https://pedo.help", fg=Color.BLUE) 151 | reply = style("[NONCE DETECTED] ", fg=Color.RED) 152 | reply += f"{mask.nick}, please click for your own good: {link}" 153 | self._reply(target, reply) 154 | 155 | @irc3.event(irc3.rfc.PRIVMSG) 156 | def not_the_only_one(self, target, event, mask, data): 157 | if event != "PRIVMSG" or not target.is_channel: 158 | return 159 | 160 | if re.match(r"(?i)does any\s?(body|one) else.*", data): 161 | self.bot.privmsg( 162 | target, f"{mask.nick}: No, you are literally the only one in the world." 163 | ) 164 | return 165 | 166 | if re.match(r"(?i)am i the only one.*", data): 167 | self.bot.privmsg(target, f"{mask.nick}: Statistically, probably not.") 168 | return 169 | 170 | @irc3.event( 171 | r"(?i):TrapBot!\S+@\S+ .*PRIVMSG (?P#DontJoinItsATrap) :.*PART THE CHANNEL.*" 172 | ) 173 | def antitrap(self, target): 174 | self.bot.part(target) 175 | self.logger.info(f"Parted {target} (antitrap)") 176 | 177 | @irc3.event( 178 | r"(?i):(?P\S+!\S+@\S+) .*PRIVMSG (?P#\S+) :.*(wh?(aa*(z|d)*|u)t?(\'?| i)s? ?up|\'?sup)\b" 179 | ) 180 | def gravity(self, mask, target): 181 | self._reply( 182 | target, 183 | f'{mask.nick}: "Up" is a direction away from the center of gravity of a celestial object.', 184 | ) 185 | 186 | @command(permission="view", aliases=["whatthecommit"]) 187 | def wtc(self, mask, target, args): 188 | """Grab a random commit message. 189 | 190 | %%wtc 191 | """ 192 | 193 | try: 194 | with self.requests.get("https://whatthecommit.com/index.txt") as response: 195 | yield f'git commit -m "{response.text.strip()}"' 196 | except RequestException: 197 | yield ( 198 | "Failed to fetch a random git commit." 199 | " Sorry, you'll have to figure one out yourself." 200 | ) 201 | -------------------------------------------------------------------------------- /cappuccino/ai.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import contextlib 17 | import random 18 | import re 19 | from datetime import UTC, datetime 20 | from timeit import default_timer as timer 21 | 22 | import irc3 23 | import markovify 24 | from humanize import intcomma, precisedelta 25 | from irc3.plugins.command import command 26 | from irc3.utils import IrcString 27 | from sqlalchemy import func, select, update 28 | from sqlalchemy.exc import IntegrityError 29 | 30 | from cappuccino import Plugin 31 | from cappuccino.db.models.ai import AIChannel, CorpusLine 32 | from cappuccino.util.channel import is_chanop 33 | from cappuccino.util.formatting import unstyle 34 | 35 | _CMD_PATTERN = re.compile(r"^\s*([.!~`$])+") 36 | _SED_CHECKER = re.compile(r"^\s*s[/|\\!.,].+") 37 | _URL_CHECKER = re.compile(r".*https?://.*", re.IGNORECASE | re.UNICODE) 38 | 39 | 40 | def _should_ignore_message(line): 41 | if not line: 42 | return None 43 | 44 | return ( 45 | _CMD_PATTERN.match(line) 46 | or _SED_CHECKER.match(line) 47 | or _URL_CHECKER.match(line) 48 | or line.startswith(("[", "\x01ACTION ")) 49 | ) 50 | 51 | 52 | @irc3.plugin 53 | class Ai(Plugin): 54 | requires = ["irc3.plugins.command", "irc3.plugins.userlist"] 55 | 56 | def __init__(self, bot): 57 | super().__init__(bot) 58 | self._ignore_nicks: list[str] = self.config.get("ignore_nicks", []) 59 | self._max_loaded_lines: int = self.config.get("max_loaded_lines", 25000) 60 | self._max_reply_length: int = self.config.get("max_reply_length", 100) 61 | self._text_model = self._create_text_model() 62 | 63 | def _create_text_model(self): 64 | self.logger.info("Creating text model...") 65 | start = datetime.now(UTC) 66 | corpus = self._get_lines() 67 | end = datetime.now(UTC) 68 | 69 | if not corpus: 70 | self.logger.warning( 71 | "Not enough lines in corpus for markovify to generate a decent reply." 72 | ) 73 | return None 74 | 75 | self.logger.debug(f"Queried {len(corpus)} rows in {precisedelta(end - start)}.") 76 | 77 | start = datetime.now(UTC) 78 | model = markovify.NewlineText( 79 | "\n".join(corpus), retain_original=False 80 | ).compile() 81 | end = datetime.now(UTC) 82 | self.logger.info(f"Created text model in {precisedelta(start - end)}.") 83 | 84 | return model 85 | 86 | def _add_line(self, line: str, channel: str): 87 | line = unstyle(line) 88 | with ( 89 | contextlib.suppress(IntegrityError), 90 | self.db_session() as session, 91 | ): 92 | ai_channel = session.scalar( 93 | select(AIChannel).where(func.lower(AIChannel.name) == channel.lower()) 94 | ) 95 | if not ai_channel: 96 | ai_channel = AIChannel(name=channel) 97 | 98 | corpus_line = CorpusLine(line=line) 99 | ai_channel.lines.append(corpus_line) 100 | session.add_all([ai_channel, corpus_line]) 101 | 102 | def _get_lines(self, channel: str | None = None) -> list[str]: 103 | select_stmt = select(CorpusLine.line) 104 | if channel: 105 | select_stmt = select(AIChannel.lines).where( 106 | func.lower(AIChannel.name) == channel.lower() 107 | ) 108 | select_stmt = select_stmt.order_by(func.random()).limit(self._max_loaded_lines) 109 | 110 | with self.db_session() as session: 111 | lines = session.scalars(select_stmt).all() 112 | 113 | return lines if len(lines) > 0 else None 114 | 115 | def _line_count(self, channel: str | None = None) -> int: 116 | select_stmt = select(func.count()).select_from(CorpusLine) 117 | if channel: 118 | select_stmt = select_stmt.where( 119 | func.lower(CorpusLine.channel_name) == channel.lower(), 120 | ) 121 | self.logger.debug(select_stmt) 122 | 123 | with self.db_session() as session: 124 | return session.scalar(select_stmt) 125 | 126 | def _is_enabled_for_channel(self, channel: str) -> bool: 127 | if not IrcString(channel).is_channel: 128 | return False 129 | 130 | with self.db_session() as session: 131 | return session.scalar( 132 | select(AIChannel.enabled).where( 133 | func.lower(AIChannel.name) == channel.lower() 134 | ) 135 | ) 136 | 137 | def _toggle(self, channel: str): 138 | new_status = not self._is_enabled_for_channel(channel) 139 | 140 | with self.db_session() as session: 141 | ai_channel = session.scalar( 142 | update(AIChannel) 143 | .returning(AIChannel) 144 | .where(func.lower(AIChannel.name) == channel.lower()) 145 | .values(enabled=new_status) 146 | ) 147 | 148 | if ai_channel is None: 149 | ai_channel = AIChannel(name=channel, enabled=new_status) 150 | session.add(ai_channel) 151 | 152 | @command() 153 | def ai(self, mask, target, args): 154 | """Toggles chattiness. 155 | 156 | %%ai [--status] 157 | """ 158 | 159 | if not target.is_channel: 160 | return "This command cannot be used in PM." 161 | 162 | if args["--status"]: 163 | line_count = self._line_count() 164 | channel_line_count = self._line_count(target) 165 | channel_percentage = 0 166 | 167 | # Percentage of global lines the current channel accounts for. 168 | if channel_line_count > 0 and line_count > 0: 169 | channel_percentage = int( 170 | round( 171 | 100 * float(channel_line_count) / float(line_count), ndigits=0 172 | ) 173 | ) 174 | 175 | ai_status = ( 176 | "enabled" if self._is_enabled_for_channel(target) else "disabled" 177 | ) 178 | line_counts = f"{intcomma(channel_line_count)}/{intcomma(line_count)}" 179 | return ( 180 | f"Chatbot is currently {ai_status} for {target}." 181 | f" Channel/global line count: {line_counts} ({channel_percentage}%)." 182 | ) 183 | 184 | if not is_chanop(self.bot, target, mask.nick): 185 | prefixes = [ 186 | prefix.value 187 | for prefix in self.bot.nickprefix 188 | if prefix is not self.bot.nickprefix.VOICE 189 | ] 190 | op_prefixes = ", ".join(prefixes) 191 | 192 | return f"You must be a channel operator ({op_prefixes}) to do that." 193 | 194 | self._toggle(target) 195 | return ( 196 | "Chatbot activated." 197 | if self._is_enabled_for_channel(target) 198 | else "Shutting up!" 199 | ) 200 | 201 | @irc3.event(irc3.rfc.PRIVMSG) 202 | def handle_line(self, target, event, mask, data): 203 | if not target.is_channel or not mask.is_user: 204 | return 205 | 206 | if mask.nick in self._ignore_nicks or mask.nick == self.bot.nick: 207 | return 208 | 209 | data = data.strip() 210 | if _should_ignore_message(data): 211 | return 212 | 213 | # Only respond to messages mentioning the bot in an active channel 214 | if self.bot.nick.lower() not in data.lower(): 215 | # Only add lines that aren't mentioning the bot 216 | self._add_line(data, target) 217 | return 218 | 219 | if not self._is_enabled_for_channel(target): 220 | return 221 | 222 | start = timer() 223 | generated_reply = self._text_model.make_short_sentence(self._max_reply_length) 224 | end = timer() 225 | self.logger.debug( 226 | f"Generating sentence took {(end - start) * 1000} milliseconds." 227 | ) 228 | 229 | if not generated_reply: 230 | self.bot.privmsg( 231 | target, 232 | random.choice( # noqa: S311 233 | ["What?", "Hmm?", "Yes?", "What do you want?"] 234 | ), 235 | ) 236 | return 237 | 238 | self.bot.privmsg(target, generated_reply.strip()) 239 | -------------------------------------------------------------------------------- /cappuccino/rice.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import inspect 17 | import re 18 | 19 | import irc3 20 | from irc3.plugins.command import command 21 | 22 | from cappuccino import Plugin 23 | from cappuccino.util.formatting import Color, style 24 | 25 | 26 | def _to_user_index(index: int): 27 | """Converts a zero-indexed value to a user-friendly value starting from 1""" 28 | return index + 1 29 | 30 | 31 | def _from_user_index(index: int): 32 | """Converts a user-supplied index to a value suitable for zero-indexed arrays""" 33 | index = int(index) 34 | return index - 1 if index >= 1 else index 35 | 36 | 37 | @irc3.plugin 38 | class Rice(Plugin): 39 | requires = ["irc3.plugins.command", "cappuccino.userdb"] 40 | 41 | def __init__(self, bot): 42 | super().__init__(bot) 43 | self._max_user_entries: int = self.config.get("max_user_entries", 6) 44 | 45 | def _generic_db(self, mask, target, args): # noqa: C901 46 | # Get name of command _generic_db is being called from. 47 | category = inspect.stack()[1][3] 48 | category = category if category.endswith("s") else category + "s" 49 | 50 | if args[""]: 51 | args[""] = [arg.strip() for arg in args[""] if arg.strip()] 52 | 53 | if len(args[""]) == 0: 54 | return f"{category} cannot be empty!" 55 | 56 | response = None 57 | 58 | if args["--add"] or args["-a"]: 59 | values = self.bot.get_user_value(mask.nick, category) or [] 60 | if len(values) + len(args[""]) > self._max_user_entries: 61 | response = ( 62 | f"You can only set {self._max_user_entries} {category}!" 63 | f" Consider deleting or replacing some." 64 | ) 65 | else: 66 | for value in args[""]: 67 | values.append(value) 68 | self.bot.set_user_value(mask.nick, category, values) 69 | response = f"{category} updated." 70 | 71 | elif args["--set"] or args["-s"]: 72 | values = args[""] 73 | if len(values) > self._max_user_entries: 74 | response = ( 75 | f"You can only set {self._max_user_entries} {category}!" 76 | f" Consider deleting or replacing some." 77 | ) 78 | else: 79 | self.bot.set_user_value(mask.nick, category, values) 80 | response = f"{category} updated." 81 | 82 | elif args["--delete"] or args["-d"]: 83 | values = self.bot.get_user_value(mask.nick, category) 84 | if not values: 85 | response = f"You do not have any {category} to remove." 86 | else: 87 | indexes = set(args[""]) 88 | if "*" in indexes: 89 | self.bot.del_user_value(mask.nick, category) 90 | response = f"Removed all of your {category}." 91 | else: 92 | deleted_list = [] 93 | for index in sorted(indexes, reverse=True): 94 | try: 95 | index = _from_user_index(index) 96 | deleted_list.append(values[index]) 97 | del values[index] 98 | except IndexError: 99 | pass 100 | except ValueError: 101 | response = "Invalid ID(s)" 102 | break 103 | if not response: 104 | if not deleted_list: 105 | response = f"No {category} were removed. Maybe you supplied the wrong IDs?" 106 | else: 107 | self.bot.set_user_value(mask.nick, category, values) 108 | deleted_list = ", ".join( 109 | [style(deleted, reset=True) for deleted in deleted_list] 110 | ) 111 | response = f"Removed {deleted_list}." 112 | 113 | elif args["--replace"] or args["-r"]: 114 | try: 115 | index = _from_user_index(args[""]) 116 | except ValueError: 117 | response = "Invalid ID" 118 | else: 119 | replacement = args[""].strip() 120 | values = self.bot.get_user_value(mask.nick, category) 121 | if not values: 122 | response = f"You do not have any {category} to replace." 123 | else: 124 | try: 125 | old_value = values[index] 126 | values[index] = replacement 127 | self.bot.set_user_value(mask.nick, category, values) 128 | old_value = style(old_value, reset=True) 129 | replacement = style(replacement, reset=True) 130 | response = f"Replaced {old_value} with {replacement}" 131 | except IndexError: 132 | response = "Invalid ID." 133 | 134 | elif args[""] is not None and re.match( 135 | "^https?://.*", args[""], re.IGNORECASE | re.DOTALL 136 | ): 137 | response = "Did you mean to use --add (-a) or --set (-s) there?" 138 | 139 | elif ( 140 | args[""] is not None 141 | and args[""].isdigit() 142 | and args[""] is None 143 | ): 144 | args[""], args[""] = None, args[""] 145 | 146 | if response: 147 | return response 148 | 149 | seperator = style(" | ", fg=Color.LIGHT_GRAY) 150 | user = args[""] or mask.nick 151 | user_prefix = style("[", fg=Color.LIGHT_GRAY) 152 | user_suffix = style("]", fg=Color.LIGHT_GRAY) 153 | user_tag = style(user, fg=Color.GREEN) 154 | user_tag = f"{user_prefix}{user_tag}{user_suffix}" 155 | 156 | if args[""] is not None: 157 | try: 158 | index = _from_user_index(args[""]) 159 | value = self.bot.get_user_value(user, category)[index] 160 | except (ValueError, IndexError, TypeError): 161 | return "Invalid ID." 162 | value = style(value, reset=True) 163 | return f"{user_tag} {value}" 164 | 165 | values = self.bot.get_user_value(user, category) 166 | if values: 167 | indexed_values = [] 168 | for index, item in enumerate(values): 169 | item = style(item, reset=True) 170 | if len(values) < 2: # noqa: PLR2004 171 | indexed_values.append(item) 172 | break 173 | index = _to_user_index(index) 174 | id_prefix = style(f"#{index}", fg=Color.PURPLE) 175 | indexed_values.append(f"{id_prefix} {item}") 176 | formatted_values = seperator.join(indexed_values) 177 | return f"{user_tag} {formatted_values}" 178 | 179 | return f"{user} has no {category}." 180 | 181 | @command(permission="view") 182 | def station(self, mask, target, args): 183 | """ 184 | %%station [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 185 | """ 186 | yield self._generic_db(mask, target, args) 187 | 188 | @command(permission="view", aliases=["desktop", "dt"]) 189 | def dtop(self, mask, target, args): 190 | """ 191 | %%dtop [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 192 | """ 193 | yield self._generic_db(mask, target, args) 194 | 195 | @command(permission="view", aliases=["git"]) 196 | def dotfiles(self, mask, target, args): 197 | """ 198 | %%dotfiles [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 199 | """ 200 | yield self._generic_db(mask, target, args) 201 | 202 | @command(permission="view", aliases=["hw"]) 203 | def handwriting(self, mask, target, args): 204 | """ 205 | %%handwriting [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 206 | """ 207 | yield self._generic_db(mask, target, args) 208 | 209 | @command(permission="view") 210 | def distro(self, mask, target, args): 211 | """ 212 | %%distro [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 213 | """ 214 | yield self._generic_db(mask, target, args) 215 | 216 | @command(permission="view", aliases=["phone", "hscr", "hs"]) 217 | def homescreen(self, mask, target, args): 218 | """ 219 | %%homescreen [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 220 | """ 221 | yield self._generic_db(mask, target, args) 222 | 223 | @command(permission="view") 224 | def selfie(self, mask, target, args): 225 | """ 226 | %%selfie [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 227 | """ 228 | yield self._generic_db(mask, target, args) 229 | 230 | @command(permission="view") 231 | def pet(self, mask, target, args): 232 | """ 233 | %%pet [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 234 | """ 235 | yield self._generic_db(mask, target, args) 236 | 237 | @command(permission="view", aliases=["site"]) 238 | def website(self, mask, target, args): 239 | """ 240 | %%website [((-s | --set) ... | (-a | --add) ... | (-d | --delete) ... | (-r | --replace) ) | [] []] 241 | """ 242 | yield self._generic_db(mask, target, args) 243 | -------------------------------------------------------------------------------- /cappuccino/urlinfo.py: -------------------------------------------------------------------------------- 1 | # This file is part of cappuccino. 2 | # 3 | # cappuccino is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # cappuccino is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with cappuccino. If not, see . 15 | 16 | import concurrent 17 | import contextlib 18 | import html 19 | import ipaddress 20 | import random 21 | import re 22 | import socket 23 | import time 24 | from copy import copy 25 | from typing import TYPE_CHECKING 26 | 27 | if TYPE_CHECKING: 28 | from email.headerregistry import ContentDispositionHeader, ContentTypeHeader 29 | 30 | from email.policy import EmailPolicy 31 | from io import StringIO 32 | from urllib.parse import urlparse 33 | 34 | import bs4 35 | import irc3 36 | import requests 37 | from humanize import naturalsize 38 | 39 | from cappuccino import Plugin 40 | from cappuccino.util.formatting import Color, style, truncate_with_ellipsis, unstyle 41 | 42 | 43 | class ResponseBodyTooLarge(requests.RequestException): 44 | pass 45 | 46 | 47 | class InvalidIPAddressError(Exception): 48 | pass 49 | 50 | 51 | class ContentTypeNotAllowedError(Exception): 52 | pass 53 | 54 | 55 | class RequestTimeout(requests.RequestException): 56 | pass 57 | 58 | 59 | def _clean_url(url: str): 60 | if url: 61 | url = url.rstrip("'.,\"\1") 62 | braces = [("{", "}"), ("<", ">"), ("[", "]"), ("(", ")")] 63 | for left_brace, right_brace in braces: 64 | if left_brace not in url and url.endswith(right_brace): 65 | url = url.rstrip(right_brace) 66 | return url 67 | 68 | 69 | def _extract_title_from_soup(soup: bs4.BeautifulSoup): 70 | if title_tag := soup.find("meta", property="og:title", content=True): 71 | return title_tag.get("content") 72 | with contextlib.suppress(AttributeError): 73 | return soup.title.string 74 | 75 | 76 | def _extract_site_name_from_soup(soup: bs4.BeautifulSoup): 77 | if site_name_tag := soup.find("meta", property="og:site_name", content=True): 78 | return site_name_tag.get("content") 79 | return None 80 | 81 | 82 | @irc3.plugin 83 | class UrlInfo(Plugin): 84 | _max_bytes = 10 * 1000 * 1000 # 10M 85 | _url_regex = re.compile(r"https?://\S+", re.IGNORECASE | re.UNICODE) 86 | _max_title_length = 300 87 | _request_timeout = 5 88 | _html_mimetypes = ["text/html", "application/xhtml+xml"] 89 | _request_chunk_size = 1024 # Bytes 90 | _allowed_content_types = ["text", "video", "application"] 91 | 92 | def __init__(self, bot): 93 | super().__init__(bot) 94 | self._ignore_nicks: list[str] = self.config.get("ignore_nicks", "").split() 95 | self._ignore_hostnames: list[str] = self.config.get("ignore_hostnames", []) 96 | self._real_user_agent: str = self.requests.headers.get("User-Agent") 97 | self._fake_user_agent: str = self.config.get( 98 | "fake_useragent", "Googlebot/2.1 (+http://www.google.com/bot.html)" 99 | ) 100 | self._fake_useragent_hostnames: list[str] = self.config.get( 101 | "fake_useragent_hostnames", [] 102 | ) 103 | 104 | @irc3.event( 105 | rf"(?iu):(?P\S+!\S+@\S+) PRIVMSG (?P#\S+) :(?P.*{_url_regex.pattern}).*" 106 | ) 107 | def on_url(self, mask, target, data): # noqa: C901 108 | if mask.nick in self._ignore_nicks or data.startswith( 109 | (self.bot.config.cmd, f"{self.bot.nick}: ") 110 | ): 111 | return 112 | 113 | urls = [_clean_url(url) for url in set(self._url_regex.findall(data))] or [] 114 | for url in urls: 115 | if urlparse(url).hostname in self._ignore_hostnames: 116 | urls.remove(url) 117 | 118 | if not urls: 119 | return 120 | 121 | random.shuffle(urls) 122 | urls = urls[:3] 123 | 124 | with concurrent.futures.ThreadPoolExecutor(max_workers=len(urls)) as executor: 125 | messages = [] 126 | self.logger.debug(f"Retrieving page titles for {urls}") 127 | 128 | future_to_url = { 129 | executor.submit(self._process_url, url): url for url in urls 130 | } 131 | for future in concurrent.futures.as_completed(future_to_url): 132 | url = future_to_url[future] 133 | hostname = urlparse(url).hostname 134 | 135 | try: 136 | hostname, title, mimetype, size = future.result() 137 | except InvalidIPAddressError: 138 | return 139 | except ContentTypeNotAllowedError as ex: 140 | self.logger.debug(ex) 141 | except (socket.gaierror, ValueError, requests.RequestException) as ex: 142 | hostname = style(hostname, fg=Color.RED) 143 | 144 | with contextlib.suppress(AttributeError, IndexError): 145 | ex = ex.args[0].reason 146 | 147 | error = style(ex, bold=True) 148 | if isinstance(ex, requests.RequestException): 149 | if ex.response is not None and ex.response.reason is not None: 150 | status_code = style(ex.response.status_code, bold=True) 151 | error = style(ex.response.reason, bold=True) 152 | messages.append(f"[ {hostname} ] {status_code} {error}") 153 | return 154 | messages.append(f"[ {hostname} ] {error}") 155 | # no exception 156 | else: 157 | hostname = style(hostname, fg=Color.GREEN) 158 | if title is not None: 159 | title = style(title, bold=True) 160 | reply = f"[ {hostname} ] {title}" 161 | if (size and mimetype) and mimetype != "text/html": 162 | size = naturalsize(size) 163 | reply = f"{reply} ({size} - {mimetype})" 164 | messages.append(reply) 165 | 166 | # Send all parsed URLs now that we have them all. 167 | if messages: 168 | pipe_character = style(" | ", fg=Color.LIGHT_GRAY) 169 | self.bot.privmsg(target, pipe_character.join(messages)) 170 | 171 | def _stream_response(self, response: requests.Response) -> str: 172 | start_time = time.time() 173 | content = StringIO() 174 | 175 | for chunk in response.iter_content(self._request_chunk_size): 176 | if time.time() - start_time >= self._request_timeout: 177 | raise RequestTimeout( 178 | f"Request timed out ({self._request_timeout} seconds)." 179 | ) 180 | if not chunk: # filter out keep-alive new chunks 181 | continue 182 | content_length = content.write(chunk.decode("UTF-8", errors="ignore")) 183 | if content_length > self._max_bytes: 184 | size = naturalsize(content_length) 185 | raise ResponseBodyTooLarge( 186 | f"Couldn't find the page title within {size}." 187 | ) 188 | 189 | return content.getvalue() 190 | 191 | def _process_url(self, url: str): 192 | urlp = urlparse(url) 193 | if urlp.netloc.lower().removeprefix("www.") == "twitter.com": 194 | urlp = urlp._replace(netloc="nitter.net") 195 | url = urlp.geturl() 196 | 197 | hostname = urlp.hostname 198 | self._validate_ip_address(hostname) 199 | hostname = hostname.removeprefix("www.") 200 | 201 | # Spoof user agent for certain sites so they give up their secrets. 202 | request = copy(self.requests) 203 | if any( 204 | f".{hostname}".endswith(f".{host}") 205 | for host in self._fake_useragent_hostnames 206 | ): 207 | request.headers.update({"User-Agent": self._fake_user_agent}) 208 | 209 | with request.get(url, stream=True) as response: 210 | if response.status_code != requests.codes.ok: 211 | response.raise_for_status() 212 | 213 | content_type = response.headers.get("Content-Type") 214 | self._validate_content_type(content_type) 215 | 216 | title, size = self._extract_title_and_size(response, content_type) 217 | 218 | return hostname, title, content_type, size 219 | 220 | def _validate_ip_address(self, hostname: str): 221 | for _, _, _, _, sockaddr in socket.getaddrinfo(hostname, None): 222 | ip = ipaddress.ip_address(sockaddr[0]) 223 | if not ip.is_global: 224 | raise InvalidIPAddressError( 225 | f"{hostname} is not a publicly routable address." 226 | ) 227 | 228 | def _validate_content_type(self, content_type: str): 229 | if content_type: 230 | header: ContentTypeHeader = EmailPolicy.header_factory( 231 | "content-type", content_type 232 | ) 233 | main_type = header.maintype 234 | if main_type not in self._allowed_content_types: 235 | raise ContentTypeNotAllowedError( 236 | f"{main_type} not in {self._allowed_content_types}" 237 | ) 238 | 239 | def _extract_title_and_size(self, response: requests.Response, content_type: str): 240 | title = None 241 | size = int(response.headers.get("Content-Length", 0)) 242 | content_disposition = response.headers.get("Content-Disposition") 243 | if content_disposition: 244 | header: ContentDispositionHeader = EmailPolicy.header_factory( 245 | "content-disposition", content_disposition 246 | ) 247 | title = header.params.get("filename") 248 | elif content_type in self._html_mimetypes or content_type == "text/plain": 249 | content = self._stream_response(response) 250 | if content and not size: 251 | size = len(content.encode("UTF-8")) 252 | 253 | soup = bs4.BeautifulSoup(content, "html5lib") 254 | title = _extract_title_from_soup(soup) 255 | 256 | site_name = _extract_site_name_from_soup(soup) 257 | if (site_name and len(site_name) < (site_name_max_size := 16)) and ( 258 | len(site_name) > site_name_max_size 259 | ): 260 | site_name = truncate_with_ellipsis(title, site_name_max_size) 261 | 262 | if not title and (content and content_type not in self._html_mimetypes): 263 | title = re.sub(r"\s+", " ", " ".join(content.split("\n"))) 264 | 265 | if title: 266 | title = unstyle(html.unescape(title).strip()) 267 | if len(title) > self._max_title_length: 268 | title = truncate_with_ellipsis(title, self._max_title_length) 269 | 270 | return title, size 271 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 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 General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------