
Discord Cli
- 11 installs
- 119 repo stars
- Updated March 14, 2026
- jackwener/discord-cli
Fetch Discord chat history, search messages, sync channels, and run AI analysis from the terminal with YAML-first structured output.
About
This CLI tool works with Discord to fetch chat history, search messages, sync channels, and run AI analysis. A developer or agent uses it for machine-readable Discord data via YAML/JSON envelopes for downstream processing.
- YAML-first structured output with token-efficient result limits
- Search, sync, and export commands for channels and messages
Discord Cli by the numbers
- 11 all-time installs (skills.sh)
- Ranked #391 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jackwener/discord-cli --skill discord-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 119 |
| Last updated | March 14, 2026 |
| Repository | jackwener/discord-cli ↗ |
What it does
Fetch Discord chat history, search messages, sync channels, and run AI analysis from the terminal with YAML-first structured output.
Files
discord-cli Skill
CLI tool for Discord — fetch chat history, search messages, sync channels, AI analysis.
Agent Defaults
When you need machine-readable output:
1. Prefer --yaml for structured output unless a strict JSON parser is required. 2. Use -n to keep result sets small and token-efficient. 3. Use -o <file> with export to save large datasets to a file. 4. Prefer specific queries over broad ones. Example: use discord search "keyword" -c general --yaml instead of scanning all channels. 5. Non-TTY stdout defaults to YAML automatically. Use OUTPUT=yaml|json|rich|auto to override. 6. All machine-readable output uses the envelope documented in SCHEMA.md.
Prerequisites
- Python 3.10+
# Install
uv tool install kabi-discord-cli
# Or: pipx install kabi-discord-cli
# Upgrade to latest (recommended to avoid API errors)
uv tool upgrade kabi-discord-cli
# Or: pipx upgrade kabi-discord-cli- Token configured via
discord auth --save
Commands
Auth & Account
discord auth --save # Auto-extract & save token
discord status # Check token validity (exit 0 = valid)
discord status --yaml # Structured auth status
discord whoami # User profile
discord whoami --yaml # Structured profileServers & Channels
discord dc guilds # List servers
discord dc guilds --yaml # YAML output
discord dc channels <GUILD> # List text channels
discord dc info <GUILD> # Server details
discord dc members <GUILD> # List membersFetching Messages
discord dc history <CHANNEL_ID> -n 1000 # Fetch history
discord dc sync <CHANNEL_ID> # Incremental sync
discord dc sync-all # Sync all known channels
discord dc tail <CHANNEL_ID> -n 20 # Follow new messages live
discord dc search <GUILD> "keyword" # Native Discord searchQuerying Stored Messages
discord search "keyword" # Search local DB
discord search "keyword" -c general # Filter by channel
discord stats # Per-channel stats
discord today # Today's messages
discord today -c general --yaml # Filter + YAML
discord top # Most active senders
discord top --hours 24 # Last 24h only
discord timeline # Activity chart
discord timeline --by hour # Hourly granularityData
discord export <CHANNEL> -f json -o out.json # Export
discord purge <CHANNEL> -y # Delete storedWorkflow: Before Using
# Always run this first to ensure token is valid
discord auth --save # Auto-extract token from browser (if needed)
discord status # Verify token worksWorkflow: Daily Sync
# 1. First time: fetch history for channels you care about
discord dc guilds --yaml
discord dc channels <guild_id> --yaml
discord dc history <channel_id> -n 2000
# 2. Daily: incremental sync
discord dc sync-all
# 3. Read today's messages (structured output for agents)
discord today --yamlNotes
- Uses Discord user token (not bot token) for read-only access
- Rate limits are handled automatically with retry
- Messages stored in SQLite at
~/Library/Application Support/discord-cli/messages.db
Safety Notes
- Do not ask users to share raw token values in chat logs.
- Prefer auto-extraction via
discord auth --saveover manual token input. - Token is stored locally and never uploaded.
# Discord user token (from browser DevTools → Network → Authorization header)
DISCORD_TOKEN=your_token_here
# Optional: Anthropic API key for AI analysis
# ANTHROPIC_API_KEY=sk-ant-...
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: input
id: version
attributes:
label: Version
description: "Run `discord --version` or `pip show kabi-discord-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug clearly.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: "Commands or steps to reproduce the issue. Use `discord -v <command>` for debug output."
render: bash
- type: textarea
id: logs
attributes:
label: Error output / logs
description: Paste any error messages or verbose output here.
render: text
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: input
id: version
attributes:
label: Current version
description: "Run `discord --version` or `pip show kabi-discord-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: false
- type: textarea
id: description
attributes:
label: Describe the feature
description: What would you like to see added or changed?
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use case
description: Why do you need this feature? How would you use it?
name: CI
on:
push:
branches: [main]
pull_request:
workflow_call:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: astral-sh/setup-uv@v6
- name: Install dependencies
run: uv sync --extra dev
- name: Lint
run: uv run ruff check .
- name: Test
run: uv run python -m pytest -q
- name: Build
run: uv build
- name: Twine check
run: uv run twine check dist/*
name: Publish to PyPI
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
verify:
uses: ./.github/workflows/ci.yml
publish:
needs: verify
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v6
- name: Build package
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
__pycache__/
__pycache__/
*.egg-info/
.venv/
.env
data/
*.db
*.db-*
.pytest_cache/
dist/
Changelog
0.2.3
- Add public-release readiness improvements for documentation, packaging, and CI.
- Add safer channel resolution with ambiguity errors for local query and data commands.
- Add
timeline --jsonand makesync-alldiscover channels directly from the Discord API. - Clarify user-token risk and optional AI installation requirements in the docs.
Contributing
Development
uv sync --extra dev --extra ai
uv run ruff check .
uv run python -m pytest
uv buildNotes
- Keep the CLI behavior scriptable and stable.
- Prefer adding tests for CLI and SQLite behavior when changing commands.
- Discord auth uses user tokens from the local machine. Do not weaken the safety messaging around that flow.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
[project]
name = "kabi-discord-cli"
version = "0.2.8"
description = "Discord CLI for local-first sync, search, export, and agent-friendly retrieval"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [{ name = "jackwener" }]
keywords = ["discord", "cli", "sqlite", "agent", "discord-cli"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Chat",
"Topic :: Utilities",
]
urls = { Homepage = "https://github.com/jackwener/discord-cli", Repository = "https://github.com/jackwener/discord-cli", Issues = "https://github.com/jackwener/discord-cli/issues" }
dependencies = [
"httpx>=0.27",
"click>=8.0",
"rich>=13.0",
"python-dotenv>=1.0",
"PyYAML>=6.0",
]
[project.optional-dependencies]
dev = ["build>=1.2", "pytest>=8.0", "pytest-asyncio>=0.24", "ruff>=0.11", "twine>=6.0"]
[project.scripts]
discord = "discord_cli.cli.main:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/discord_cli"]
[tool.hatch.build.targets.sdist]
include = [
"src/discord_cli",
"tests",
"README.md",
"README_CN.md",
"SKILL.md",
".env.example",
"LICENSE",
"CHANGELOG.md",
"CONTRIBUTING.md",
]
[tool.ruff]
target-version = "py310"
line-length = 100
discord-cli
  
English
推荐项目
- xiaohongshu-cli — 小红书笔记与账号工作流 CLI
- twitter-cli — Twitter/X 时间线、搜索与发推 CLI
- bilibili-cli — Bilibili 视频、用户、搜索与动态 CLI
- tg-cli — Telegram 本地优先同步、检索与导出 CLI
一个面向本地缓存和 AI agent 的 Discord CLI:把消息同步到 SQLite,本地搜索、导出和分析,再把结构化结果交给外部 agent。
discord-cli 通过 Discord HTTP API 访问你本机登录态里的 user token。它只适合你自己控制的账号和设备。
风险提示
- discord-cli 会读取本地 Discord/浏览器会话中的 user token
- 使用 user token 访问 Discord API 可能触发平台风控或账号限制
- 只建议在你自己的账号上使用,并且要清楚这类自动化的风险
功能特性
- 基于 SQLite 的本地消息缓存,支持 history、sync、search、export 和 analytics
discord dc sync-all会直接从 API 发现可访问的文字频道,空库也能冷启动- 查询命令支持
--json,方便脚本和 AI agent 调用 timeline --json提供机器可读的活跃度数据- 结构化输出协议见 SCHEMA.md
AI Agent 提示: 需要结构化输出时始终使用--json,不要解析默认的富文本显示。用-n控制返回数量。
- 更安全的 channel 解析:遇到重名或模糊匹配会直接报错,而不是误操作
安装
# PyPI
uv tool install kabi-discord-cli
# 或
pipx install kabi-discord-cli
# 从 GitHub 安装
uv tool install git+https://github.com/jackwener/discord-cli.git
# 从源码安装
git clone git@github.com:jackwener/discord-cli.git
cd discord-cli
uv sync --extra dev升级到最新版本:
uv tool upgrade kabi-discord-cli
# 或:pipx upgrade kabi-discord-cli提示: 建议定期升级,避免因版本过旧导致的 API 调用异常。
快速开始
# 从本地 Discord / 浏览器登录态提取 token 并保存
discord auth --save
# 检查认证
discord status
discord whoami
# 浏览 guild 和 channel
discord dc guilds
discord dc channels <guild_id>
# 冷启动同步本地库
discord dc sync-all -n 500
# 查询本地缓存
discord today
discord recent -n 50
discord search "rust" -c general --json
discord timeline --by hour --json命令一览
认证与账号
| 命令 | 说明 |
|---|---|
auth [--save] | 从本地 Discord/浏览器会话提取 token |
status | 检查当前 token 是否有效 |
whoami [--json] | 查看当前账号资料 |
Discord API (discord dc ...)
| 命令 | 说明 |
|---|---|
dc guilds [--json] | 列出已加入的 guild |
dc channels GUILD [--json] | 列出 guild 下的文字频道 |
dc history CHANNEL [-n 1000] | 拉取单个频道历史消息 |
dc sync CHANNEL [-n 5000] | 增量同步单个频道 |
dc sync-all [-n 5000] | 自动发现并同步可访问的文字频道 |
dc tail CHANNEL [--once] | 像 tail -f 一样轮询新消息 |
dc search GUILD KEYWORD [-c CHANNEL_ID] [--json] | 使用 Discord 原生搜索 |
dc members GUILD [--max 50] [--json] | 列出 guild 成员 |
dc info GUILD [--json] | 查看 guild 详情 |
本地查询
| 命令 | 说明 |
|---|---|
search KEYWORD [-c CHANNEL] [-n 50] [--json] | 搜索本地缓存消息 |
recent [-c CHANNEL] [--hours N] [-n 50] [--json] | 查看最新缓存消息 |
stats [--json] | 各频道消息统计 |
today [-c CHANNEL] [--json] | 查看今天的消息 |
top [-c CHANNEL] [--hours N] [--json] | 查看最活跃发言人 |
| `timeline [-c CHANNEL] [--hours N] [--by day\ | hour] [--json]` |
数据管理
| 命令 | 说明 |
|---|---|
| `export CHANNEL [-f text\ | json] [-o FILE] [--hours N]` |
purge CHANNEL [-y] | 删除某个频道的本地缓存 |
行为说明
- 大多数顶层查询命令读的是本地 SQLite,不是每次都直接查 Discord
discord dc sync-all现在会从 API 发现 guild/channel,所以空数据库也能直接冷启动- channel 名称解析基于本地数据库;如果一个名字命中多个频道,CLI 会报错并要求你改用更具体的名字或 channel ID
仓库里还附带了给 agent 使用的 SKILL.md。
开发
uv sync --extra dev
uv run ruff check .
uv run python -m pytest
uv buildLicense
Apache-2.0
discord-cli
  
PyPI package name: `kabi-discord-cli` — install with uv tool install kabi-discord-cli中文
More Projects
- xiaohongshu-cli — Xiaohongshu (小红书) CLI for notes and account workflows
- twitter-cli — Twitter/X CLI for timelines, bookmarks, and posting
- bilibili-cli — Bilibili CLI for videos, users, search, and feeds
- tg-cli — Telegram CLI for local-first sync, search, and export
Telethon-style local-first tooling for Discord: sync messages into SQLite, search them from the terminal, export structured results, and feed them to AI agents.
discord-cli uses the Discord HTTP API with a user token from your local session. It is meant for accounts you control, on machines you control.
Warning
- discord-cli reads a Discord user token from your local Discord/browser session.
- Discord may restrict or suspend accounts that automate user-token traffic.
- Use it only on your own account and only if you understand the risk.
Features
- Local-first SQLite storage for history, sync, search, export, and analytics
discord dc sync-alldiscovers accessible text channels and bootstraps from the API- Query commands support
--yamland--jsonfor scripting and AI agent integration - Non-TTY stdout defaults to YAML; override with
OUTPUT=yaml|json|rich|auto - Structured output contract: SCHEMA.md
AI Agent Tip: Prefer--yamlfor structured output unless a strict JSON parser is required. Use-nto limit results.
- Safer local channel resolution for
search,recent,today,export, andpurge
Installation
# PyPI
uv tool install kabi-discord-cli
# or
pipx install kabi-discord-cli
# From GitHub
uv tool install git+https://github.com/jackwener/discord-cli.git
# From source
git clone git@github.com:jackwener/discord-cli.git
cd discord-cli
uv sync --extra devUpgrade to the latest version:
uv tool upgrade kabi-discord-cli
# Or: pipx upgrade kabi-discord-cliTip: Upgrade regularly to avoid unexpected errors from outdated API handling.
Quick Start
# Extract and save a token from your local Discord/browser session
discord auth --save
# Verify auth
discord status
discord whoami
# Explore guilds and channels
discord dc guilds
discord dc channels <guild_id>
# Bootstrap local storage
discord dc sync-all -n 500
# Query local cache
discord today
discord recent -n 50
discord search "rust" -c general --json
discord timeline --by hour --jsonCommands
Auth & Account
| Command | Description |
|---|---|
auth [--save] | Extract a token from local Discord/browser session |
status | Check if the configured token is valid |
whoami [--json] | Show the current Discord profile |
Discord API (discord dc ...)
| Command | Description |
|---|---|
dc guilds [--json] | List joined guilds |
dc channels GUILD [--json] | List text channels in a guild |
dc history CHANNEL [-n 1000] | Fetch message history for one channel |
dc sync CHANNEL [-n 5000] | Incrementally sync one channel |
dc sync-all [-n 5000] | Discover and sync accessible text channels |
dc tail CHANNEL [--once] | Poll and follow new messages like tail -f |
dc search GUILD KEYWORD [-c CHANNEL_ID] [--json] | Use Discord native search |
dc members GUILD [--max 50] [--json] | List guild members |
dc info GUILD [--json] | Show guild info |
Local Query
| Command | Description |
|---|---|
search KEYWORD [-c CHANNEL] [-n 50] [--json] | Search locally stored messages |
recent [-c CHANNEL] [--hours N] [-n 50] [--json] | Show newest locally stored messages |
stats [--json] | Message statistics per channel |
today [-c CHANNEL] [--json] | Show today's messages |
top [-c CHANNEL] [--hours N] [--json] | Top senders |
| `timeline [-c CHANNEL] [--hours N] [--by day\ | hour] [--json]` |
Data
| Command | Description |
|---|---|
| `export CHANNEL [-f text\ | json] [-o FILE] [--hours N]` |
purge CHANNEL [-y] | Delete stored messages for a channel |
Behavior Notes
- Most top-level query commands read from local SQLite, not directly from Discord.
discord dc sync-allnow bootstraps by discovering guilds and channels through the API, so it works on a fresh database.- Channel names are resolved against the local database. If a name matches multiple channels, the CLI will stop and ask you to use a more specific name or a channel ID.
discord-cli also ships with SKILL.md for agent integration.
Development
uv sync --extra dev
uv run ruff check .
uv run python -m pytest
uv buildLicense
Apache-2.0
Structured Output Schema
discord-cli uses a shared agent-friendly envelope for machine-readable output.
Success
ok: true
schema_version: "1"
data: ...Error
ok: false
schema_version: "1"
error:
code: channel_resolution_error
message: Channel 'gen' matches multiple local channels.Notes
--yamland--jsonboth use this envelope- non-TTY stdout defaults to YAML
- query commands return arrays or dicts under
data statusreturnsdata.authenticatedplusdata.userwhoamireturnsdata.user
"""discord-cli — Discord CLI for fetching chat history."""
try:
from importlib.metadata import version
__version__ = version("kabi-discord-cli")
except Exception:
__version__ = "0.0.0"
"""Discord token extraction from local browser and Discord client data."""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
# Discord token regex patterns
# Tokens can be:
# - Regular user token: base64(user_id).timestamp.hmac
# - MFA token: mfa.base64_encoded_string
_TOKEN_PATTERNS = [
re.compile(r'[\w-]{24,}\.[\w-]{6}\.[\w-]{27,}'),
re.compile(r'mfa\.[\w-]{84}'),
]
def _get_search_paths() -> list[tuple[str, Path]]:
"""Return list of (source_name, leveldb_path) to search for tokens."""
home = Path.home()
if sys.platform == "darwin":
paths = [
("Discord App", home / "Library/Application Support/discord/Local Storage/leveldb"),
("Discord PTB", home / "Library/Application Support/discordptb/Local Storage/leveldb"),
("Discord Canary", home / "Library/Application Support/discordcanary/Local Storage/leveldb"),
("Chrome", home / "Library/Application Support/Google/Chrome/Default/Local Storage/leveldb"),
("Brave", home / "Library/Application Support/BraveSoftware/Brave-Browser/Default/Local Storage/leveldb"),
("Edge", home / "Library/Application Support/Microsoft Edge/Default/Local Storage/leveldb"),
("Firefox", home / "Library/Application Support/Firefox/Profiles"),
]
elif os.name == "nt":
appdata = Path(os.environ.get("APPDATA", ""))
local_appdata = Path(os.environ.get("LOCALAPPDATA", ""))
paths = [
("Discord App", appdata / "discord/Local Storage/leveldb"),
("Discord PTB", appdata / "discordptb/Local Storage/leveldb"),
("Discord Canary", appdata / "discordcanary/Local Storage/leveldb"),
("Chrome", local_appdata / "Google/Chrome/User Data/Default/Local Storage/leveldb"),
("Brave", local_appdata / "BraveSoftware/Brave-Browser/User Data/Default/Local Storage/leveldb"),
("Edge", local_appdata / "Microsoft/Edge/User Data/Default/Local Storage/leveldb"),
]
else: # Linux
config = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))
paths = [
("Discord App", config / "discord/Local Storage/leveldb"),
("Discord PTB", config / "discordptb/Local Storage/leveldb"),
("Discord Canary", config / "discordcanary/Local Storage/leveldb"),
("Chrome", config / "google-chrome/Default/Local Storage/leveldb"),
("Brave", config / "BraveSoftware/Brave-Browser/Default/Local Storage/leveldb"),
]
return [(name, p) for name, p in paths if p.exists()]
def _extract_tokens_from_file(filepath: Path) -> list[str]:
"""Extract Discord tokens from a single file by regex scanning."""
tokens: list[str] = []
try:
data = filepath.read_bytes().decode("utf-8", errors="ignore")
for pattern in _TOKEN_PATTERNS:
tokens.extend(pattern.findall(data))
except (OSError, PermissionError):
pass
return tokens
def find_tokens() -> list[dict]:
"""Scan known browser/Discord client paths for tokens.
Returns list of {source, token} dicts, deduplicated by token.
"""
search_paths = _get_search_paths()
found: dict[str, str] = {} # token -> source
for source_name, db_path in search_paths:
if not db_path.is_dir():
continue
# Scan .ldb and .log files
for ext in ("*.ldb", "*.log"):
for filepath in db_path.glob(ext):
for token in _extract_tokens_from_file(filepath):
if token not in found:
found[token] = source_name
return [{"source": source, "token": token} for token, source in found.items()]
def save_token_to_env(token: str, env_path: Path | None = None) -> Path:
"""Save token to .env file."""
if env_path is None:
env_path = Path.cwd() / ".env"
lines = []
token_found = False
if env_path.exists():
for line in env_path.read_text().splitlines():
if line.startswith("DISCORD_TOKEN="):
lines.append(f"DISCORD_TOKEN={token}")
token_found = True
else:
lines.append(line)
if not token_found:
lines.append(f"DISCORD_TOKEN={token}")
env_path.write_text("\n".join(lines) + "\n")
return env_path
"""Helpers for resolving stored channel names safely."""
import click
from ..db import ChannelResolutionError, MessageDB
from ._output import emit_error
def resolve_channel_id_or_raise(db: MessageDB, channel: str) -> str:
"""Resolve a stored channel ID or raise a CLI-friendly error."""
try:
return db.resolve_channel(channel)["channel_id"]
except ChannelResolutionError as exc:
if emit_error("channel_resolution_error", str(exc)):
raise SystemExit(1) from None
raise click.ClickException(str(exc)) from exc
"""Shared structured output helpers for discord-cli."""
from __future__ import annotations
import json
import os
import sys
from typing import Any, Callable
import click
import yaml
_OUTPUT_ENV = "OUTPUT"
_SCHEMA_VERSION = "1"
def default_structured_format(*, as_json: bool, as_yaml: bool) -> str | None:
"""Resolve explicit flags first, then env override, then TTY default."""
if as_json and as_yaml:
raise click.UsageError("Use only one of --json or --yaml.")
if as_yaml:
return "yaml"
if as_json:
return "json"
output_mode = os.getenv(_OUTPUT_ENV, "auto").strip().lower()
if output_mode == "yaml":
return "yaml"
if output_mode == "json":
return "json"
if output_mode == "rich":
return None
if not sys.stdout.isatty():
return "yaml"
return None
def structured_output_options(command: Callable) -> Callable:
"""Add --json/--yaml options to a Click command."""
command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML")(command)
command = click.option("--json", "as_json", is_flag=True, help="Output as JSON")(command)
return command
def dump_structured(data: Any, *, fmt: str) -> str:
"""Serialize data to JSON or YAML text."""
if fmt == "json":
return json.dumps(data, ensure_ascii=False, indent=2, default=str)
if fmt == "yaml":
return yaml.safe_dump(
data,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
)
raise ValueError(f"Unsupported structured format: {fmt}")
def emit_structured(data: Any, *, as_json: bool, as_yaml: bool) -> bool:
"""Emit structured output and return True when a structured format was used."""
fmt = default_structured_format(as_json=as_json, as_yaml=as_yaml)
if not fmt:
return False
click.echo(dump_structured(_normalize_success_payload(data), fmt=fmt))
return True
def success_payload(data: Any) -> dict[str, Any]:
"""Wrap structured success data in the shared agent schema."""
return {
"ok": True,
"schema_version": _SCHEMA_VERSION,
"data": data,
}
def error_payload(code: str, message: str, *, details: Any | None = None) -> dict[str, Any]:
"""Wrap structured error data in the shared agent schema."""
error = {
"code": code,
"message": message,
}
if details is not None:
error["details"] = details
return {
"ok": False,
"schema_version": _SCHEMA_VERSION,
"error": error,
}
def _normalize_success_payload(data: Any) -> Any:
"""Wrap plain structured data in the shared agent success schema."""
if isinstance(data, dict) and data.get("schema_version") == _SCHEMA_VERSION and "ok" in data:
return data
return success_payload(data)
def emit_error(
code: str,
message: str,
*,
as_json: bool | None = None,
as_yaml: bool | None = None,
details: Any | None = None,
) -> bool:
"""Emit a structured error when the active output mode is machine-readable."""
if as_json is None or as_yaml is None:
ctx = click.get_current_context(silent=True)
params = ctx.params if ctx is not None else {}
as_json = bool(params.get("as_json", False)) if as_json is None else as_json
as_yaml = bool(params.get("as_yaml", False)) if as_yaml is None else as_yaml
fmt = default_structured_format(as_json=bool(as_json), as_yaml=bool(as_yaml))
if fmt is None:
return False
click.echo(dump_structured(error_payload(code, message, details=details), fmt=fmt))
return True
"""Data commands — export, purge."""
import json
import os
import sys
import click
from rich.console import Console
import yaml
from ._channels import resolve_channel_id_or_raise
from ._output import default_structured_format, error_payload
from ..db import MessageDB
console = Console(stderr=True)
@click.group("data", invoke_without_command=True)
def data_group():
"""Data management commands (registered at top-level)."""
pass
@data_group.command("export")
@click.argument("channel")
@click.option("-f", "--format", "fmt", type=click.Choice(["text", "json", "yaml"]), default="text")
@click.option("-o", "--output", "output_file", help="Output file path")
@click.option("--hours", type=int, help="Only export last N hours")
def export(channel: str, fmt: str, output_file: str | None, hours: int | None):
"""Export messages from CHANNEL to text or JSON."""
with MessageDB() as db:
channel_id = resolve_channel_id_or_raise(db, channel)
msgs = db.get_recent(channel_id=channel_id, hours=hours, limit=100000)
if not msgs:
structured_fmt = fmt if fmt in {"json", "yaml"} else default_structured_format(as_json=False, as_yaml=False)
if structured_fmt in {"json", "yaml"} and output_file is None:
click.echo(
(
json.dumps(error_payload("no_messages", f"No messages found for '{channel}'."), ensure_ascii=False, indent=2, default=str)
if structured_fmt == "json"
else yaml.safe_dump(error_payload("no_messages", f"No messages found for '{channel}'."), allow_unicode=True, sort_keys=False, default_flow_style=False)
)
)
raise SystemExit(1) from None
console.print(f"[yellow]No messages found for '{channel}'.[/yellow]")
return
auto_yaml = fmt == "text" and output_file is None and os.getenv("OUTPUT", "auto").strip().lower() != "rich" and not sys.stdout.isatty()
if fmt == "json":
content = json.dumps(msgs, ensure_ascii=False, indent=2, default=str)
elif fmt == "yaml" or auto_yaml:
content = yaml.safe_dump(msgs, allow_unicode=True, sort_keys=False, default_flow_style=False)
else:
lines = []
for msg in msgs:
ts = (msg.get("timestamp") or "")[:19]
sender = msg.get("sender_name") or "Unknown"
text = msg.get("content") or ""
lines.append(f"[{ts}] {sender}: {text}")
content = "\n".join(lines)
if output_file:
with open(output_file, "w", encoding="utf-8") as f:
f.write(content)
console.print(f"[green]✓[/green] Exported {len(msgs)} messages to {output_file}")
else:
console.print(content)
@data_group.command("purge")
@click.argument("channel")
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation")
def purge(channel: str, yes: bool):
"""Delete all stored messages for CHANNEL."""
with MessageDB() as db:
channel_id = resolve_channel_id_or_raise(db, channel)
if not yes:
count = db.count(channel_id)
if not click.confirm(f"Delete {count} messages from channel {channel_id}?"):
return
deleted = db.delete_channel(channel_id)
console.print(f"[green]✓[/green] Deleted {deleted} messages")
"""Discord subcommands — guilds, channels, history, sync, sync-all, search, members."""
import asyncio
from contextlib import suppress
import click
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
from ..client import (
fetch_messages,
get_client,
get_guild_info,
list_channels,
list_guilds,
list_members,
resolve_guild_id,
search_guild_messages,
)
from ..db import MessageDB
from ._output import emit_error, emit_structured, structured_output_options
console = Console(stderr=True)
@click.group("dc")
def discord_group():
"""Discord operations — list servers, fetch history, sync."""
pass
async def _fetch_channel_context(client, channel_id: str) -> dict[str, str | None]:
"""Resolve channel and guild names for a channel."""
channel_name = None
guild_name = None
guild_id = None
with suppress(Exception):
response = await client.get(f"/channels/{channel_id}")
if response.status_code == 200:
data = response.json()
channel_name = data.get("name")
guild_id = data.get("guild_id")
if guild_id:
guild = await get_guild_info(client, guild_id)
if guild:
guild_name = guild.get("name")
return {
"channel_name": channel_name,
"guild_name": guild_name,
"guild_id": guild_id,
}
def _annotate_messages(messages: list[dict], context: dict[str, str | None]) -> list[dict]:
"""Attach channel and guild metadata to fetched messages."""
for msg in messages:
msg["guild_id"] = context.get("guild_id")
msg["guild_name"] = context.get("guild_name")
msg["channel_name"] = context.get("channel_name")
return messages
def _format_message(msg: dict, *, include_channel: bool = False) -> str:
"""Format a single message for console output."""
ts = str(msg.get("timestamp", ""))[:19]
sender = msg.get("sender_name") or "Unknown"
content = (msg.get("content") or "").replace("\n", " ")[:300]
channel_name = msg.get("channel_name") or ""
prefix = f"[cyan]#{channel_name}[/cyan] | " if include_channel and channel_name else ""
return f"[dim]{ts}[/dim] {prefix}[bold]{sender}[/bold]: {content}"
async def _tail_fetch_once(
client,
db: MessageDB,
channel_id: str,
*,
after: str | None,
fetch_limit: int,
context: dict[str, str | None],
store: bool,
) -> tuple[list[dict], str | None, int]:
"""Fetch a single incremental batch for tail mode."""
messages = await fetch_messages(client, channel_id, limit=fetch_limit, after=after)
if not messages:
return [], after, 0
_annotate_messages(messages, context)
inserted = db.insert_batch(messages) if store else 0
return messages, messages[-1]["msg_id"], inserted
@discord_group.command("guilds")
@structured_output_options
def dc_guilds(as_json: bool, as_yaml: bool):
"""List joined Discord servers."""
async def _run():
async with get_client() as client:
return await list_guilds(client)
guilds = asyncio.run(_run())
if emit_structured(guilds, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title="Discord Servers")
table.add_column("ID", style="dim")
table.add_column("Name", style="bold")
table.add_column("Owner", justify="center")
for g in guilds:
table.add_row(g["id"], g["name"], "✓" if g["owner"] else "")
console.print(table)
console.print(f"\nTotal: {len(guilds)} servers")
@discord_group.command("channels")
@click.argument("guild")
@structured_output_options
def dc_channels(guild: str, as_json: bool, as_yaml: bool):
"""List text channels in a GUILD (server ID or name)."""
async def _run():
async with get_client() as client:
guild_id = await resolve_guild_id(client, guild)
if not guild_id:
if emit_error("guild_not_found", f"Guild '{guild}' not found.", as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None
console.print(f"[red]Guild '{guild}' not found.[/red]")
return []
return await list_channels(client, guild_id)
channels = asyncio.run(_run())
if not channels:
return
if emit_structured(channels, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title="Text Channels")
table.add_column("ID", style="dim")
table.add_column("Name", style="bold")
table.add_column("Topic", max_width=50)
for ch in channels:
table.add_row(ch["id"], f"#{ch['name']}", (ch.get("topic") or "")[:50])
console.print(table)
console.print(f"\nTotal: {len(channels)} text channels")
@discord_group.command("history")
@click.argument("channel")
@click.option("-n", "--limit", default=1000, help="Max messages to fetch")
@click.option("--guild-name", help="Guild name to store with messages")
@click.option("--channel-name", help="Channel name to store with messages")
@structured_output_options
def dc_history(channel: str, limit: int, guild_name: str | None, channel_name: str | None, as_json: bool, as_yaml: bool):
"""Fetch historical messages from CHANNEL (channel ID)."""
async def _run():
with MessageDB() as db:
async with get_client() as client:
context = await _fetch_channel_context(client, channel)
if channel_name:
context["channel_name"] = channel_name
elif context.get("channel_name") is None:
context["channel_name"] = channel
if guild_name:
context["guild_name"] = guild_name
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task(
f"Fetching messages from {context.get('channel_name') or channel}...",
total=None,
)
messages = await fetch_messages(client, channel, limit=limit)
progress.update(task, description=f"Fetched {len(messages)} messages")
_annotate_messages(messages, context)
inserted = db.insert_batch(messages)
return len(messages), inserted
total, inserted = asyncio.run(_run())
payload = {"fetched": total, "stored": inserted}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"\n[green]✓[/green] Fetched {total} messages, stored {inserted} new")
@discord_group.command("sync")
@click.argument("channel")
@click.option("-n", "--limit", default=5000, help="Max messages per sync")
@structured_output_options
def dc_sync(channel: str, limit: int, as_json: bool, as_yaml: bool):
"""Incremental sync — fetch only new messages from CHANNEL."""
async def _run():
with MessageDB() as db:
last_id = db.get_last_msg_id(channel)
if last_id:
console.print(f"Syncing from msg_id > {last_id}...")
async with get_client() as client:
context = await _fetch_channel_context(client, channel)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task_id = progress.add_task(
f"Syncing {context.get('channel_name') or channel}...",
total=None,
)
messages = await fetch_messages(client, channel, limit=limit, after=last_id)
progress.update(task_id, description=f"Fetched {len(messages)} new messages")
_annotate_messages(messages, context)
inserted = db.insert_batch(messages)
return len(messages), inserted
total, inserted = asyncio.run(_run())
payload = {"fetched": total, "stored": inserted}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"\n[green]✓[/green] Synced {total} messages, stored {inserted} new")
@discord_group.command("tail")
@click.argument("channel")
@click.option("-n", "--limit", default=20, help="Show last N messages before following")
@click.option("--interval", default=5.0, type=click.FloatRange(min=0.5), help="Polling interval in seconds")
@click.option("--poll-limit", default=100, type=click.IntRange(1, 100), help="Max new messages fetched per poll")
@click.option("--store/--no-store", default=True, help="Store tailed messages in local SQLite")
@click.option("--once", is_flag=True, help="Show initial snapshot and exit")
def dc_tail(channel: str, limit: int, interval: float, poll_limit: int, store: bool, once: bool):
"""Tail a channel and follow new messages."""
async def _run():
with MessageDB() as db:
async with get_client() as client:
context = await _fetch_channel_context(client, channel)
channel_label = context.get("channel_name") or channel
guild_label = context.get("guild_name")
scope = f"{guild_label} > #{channel_label}" if guild_label else f"#{channel_label}"
last_id = db.get_last_msg_id(channel)
if limit > 0:
initial = await fetch_messages(client, channel, limit=limit)
_annotate_messages(initial, context)
if store and initial:
db.insert_batch(initial)
for msg in initial:
console.print(_format_message(msg))
if initial:
last_id = initial[-1]["msg_id"]
elif last_id is None:
latest = await fetch_messages(client, channel, limit=1)
if latest:
_annotate_messages(latest, context)
if store:
db.insert_batch(latest)
last_id = latest[-1]["msg_id"]
if once:
return
console.print(
f"\n[green]Watching[/green] {scope} "
f"[dim](poll every {interval:g}s, Ctrl-C to stop)[/dim]"
)
while True:
messages, last_id, inserted = await _tail_fetch_once(
client,
db,
channel,
after=last_id,
fetch_limit=poll_limit,
context=context,
store=store,
)
for msg in messages:
console.print(_format_message(msg))
if messages and store:
console.print(f"[dim]+{inserted} stored[/dim]")
await asyncio.sleep(interval)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Stopped tailing.[/yellow]")
@discord_group.command("sync-all")
@click.option("-n", "--limit", default=5000, help="Max messages per channel")
def dc_sync_all(limit: int):
"""Sync ALL channels in the database."""
async def _run():
with MessageDB() as db:
async with get_client() as client:
guilds = await list_guilds(client)
channels: list[dict[str, str | None]] = []
for guild in guilds:
guild_channels = await list_channels(client, guild["id"])
for channel in guild_channels:
channels.append(
{
"guild_id": guild["id"],
"guild_name": guild["name"],
"channel_id": channel["id"],
"channel_name": channel["name"],
}
)
if not channels:
if emit_error("no_channels", "No text channels found for this account."):
return {}
console.print("[yellow]No text channels found for this account.[/yellow]")
return {}
console.print(
f"Discovered {len(channels)} channels across {len(guilds)} guilds. Syncing..."
)
results: dict[str, int] = {}
for ch in channels:
ch_id = ch["channel_id"]
ch_name = ch.get("channel_name") or ch_id
last_id = db.get_last_msg_id(ch_id)
try:
messages = await fetch_messages(client, ch_id, limit=limit, after=last_id)
for msg in messages:
msg["guild_name"] = ch.get("guild_name")
msg["channel_name"] = ch.get("channel_name")
inserted = db.insert_batch(messages)
results[ch_name] = inserted
if inserted > 0:
console.print(f" [green]✓[/green] {ch_name}: +{inserted}")
else:
console.print(f" [dim]✓ {ch_name}: no new messages[/dim]")
except Exception as e:
console.print(f" [red]✗ {ch_name}: {e}[/red]")
results[ch_name] = 0
return results
results = asyncio.run(_run())
total_new = sum(results.values())
console.print(f"\n[green]✓[/green] Synced {total_new} new messages across {len(results)} channels")
@discord_group.command("search")
@click.argument("guild")
@click.argument("keyword")
@click.option("-c", "--channel", help="Filter by channel ID")
@click.option("-n", "--limit", default=25, help="Max results")
@structured_output_options
def dc_search(guild: str, keyword: str, channel: str | None, limit: int, as_json: bool, as_yaml: bool):
"""Search messages in a GUILD by KEYWORD (Discord native search)."""
async def _run():
async with get_client() as client:
guild_id = await resolve_guild_id(client, guild)
if not guild_id:
if emit_error("guild_not_found", f"Guild '{guild}' not found.", as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None
console.print(f"[red]Guild '{guild}' not found.[/red]")
return []
return await search_guild_messages(client, guild_id, keyword, channel_id=channel, limit=limit)
results = asyncio.run(_run())
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No messages found.[/yellow]")
return
if emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
for msg in results:
ts = str(msg.get("timestamp", ""))[:19]
sender = msg.get("sender_name") or "Unknown"
content = (msg.get("content") or "")[:200]
console.print(f"[dim]{ts}[/dim] [bold]{sender}[/bold]: {content}")
console.print(f"\n[dim]Found {len(results)} messages[/dim]")
@discord_group.command("members")
@click.argument("guild")
@click.option("-n", "--max", "limit", default=50, help="Max members to list")
@structured_output_options
def dc_members(guild: str, limit: int, as_json: bool, as_yaml: bool):
"""List members of a GUILD (server)."""
async def _run():
async with get_client() as client:
guild_id = await resolve_guild_id(client, guild)
if not guild_id:
if emit_error("guild_not_found", f"Guild '{guild}' not found.", as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None
console.print(f"[red]Guild '{guild}' not found.[/red]")
return []
return await list_members(client, guild_id, limit=limit)
members = asyncio.run(_run())
if not members:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No members found (may require Privileged Intents).[/yellow]")
return
if emit_structured(members, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title=f"Members ({len(members)})")
table.add_column("ID", style="dim")
table.add_column("Username", style="bold")
table.add_column("Display", style="cyan")
table.add_column("Nick", style="green")
table.add_column("Bot", justify="center")
for m in members:
display = m.get("global_name") or ""
table.add_row(
m["id"],
f"@{m['username']}" if m.get("username") else "—",
display,
m.get("nick") or "",
"🤖" if m.get("bot") else "",
)
console.print(table)
@discord_group.command("info")
@click.argument("guild")
@structured_output_options
def dc_info(guild: str, as_json: bool, as_yaml: bool):
"""Show detailed info about a GUILD (server)."""
async def _run():
async with get_client() as client:
guild_id = await resolve_guild_id(client, guild)
if not guild_id:
if emit_error("guild_not_found", f"Could not find guild: {guild}", as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None
return None
return await get_guild_info(client, guild_id)
info = asyncio.run(_run())
if not info:
console.print(f"[red]Could not find guild: {guild}[/red]")
return
if emit_structured(info, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title="Guild Info", show_header=False)
table.add_column("Field", style="bold")
table.add_column("Value")
for k, v in info.items():
table.add_row(k, str(v) if v is not None else "—")
console.print(table)
"""discord-cli — CLI entry point."""
import logging
import click
from rich.console import Console
from rich.table import Table
from .data import data_group
from .discord_cmds import discord_group
from ._output import emit_structured, error_payload, structured_output_options, success_payload
from .query import query_group
console = Console(stderr=True)
def _discord_user_payload(user: dict) -> dict[str, object]:
"""Normalize Discord user info for structured agent output."""
return {
"id": user.get("id", ""),
"name": user.get("global_name") or user.get("username", ""),
"username": user.get("username", ""),
"global_name": user.get("global_name") or "",
"email": user.get("email") or "",
"phone": user.get("phone") or "",
"mfa_enabled": bool(user.get("mfa_enabled", False)),
"premium_type": user.get("premium_type", 0),
"created_at": user.get("created_at", ""),
}
@click.group()
@click.version_option(package_name="kabi-discord-cli")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
def cli(verbose: bool):
"""discord — CLI for fetching Discord chat history and searching messages."""
level = logging.DEBUG if verbose else logging.WARNING
logging.basicConfig(level=level, format="%(name)s: %(message)s")
@cli.command("auth")
@click.option("--save", is_flag=True, help="Save found token to .env automatically")
def auth(save: bool):
"""Extract Discord token from local browser/Discord client."""
import httpx
from ..auth import find_tokens, save_token_to_env
console.print(
"[yellow]Warning:[/yellow] discord-cli uses a Discord user token from your local "
"session. This may violate Discord's terms or trigger account restrictions. "
"Use it only on accounts you control and at your own risk."
)
console.print("[dim]Scanning for Discord tokens...[/dim]")
results = find_tokens()
if not results:
console.print("[red]No tokens found.[/red]")
console.print(
"[dim]Make sure Discord desktop app or browser is logged in.[/dim]"
)
return
console.print(f"[dim]Found {len(results)} candidate token(s), validating...[/dim]")
# Validate each token against the API
valid_token = None
valid_source = None
user_info = None
for r in results:
token = r["token"]
try:
resp = httpx.get(
"https://discord.com/api/v10/users/@me",
headers={"Authorization": token},
timeout=10.0,
)
if resp.status_code == 200:
user_info = resp.json()
valid_token = token
valid_source = r["source"]
break
except Exception:
continue
if not valid_token or not user_info:
console.print("[red]No valid token found. All tokens returned 401.[/red]")
console.print("[dim]Try logging into Discord in your browser and retry.[/dim]")
return
masked = f"{valid_token[:8]}...{valid_token[-8:]}"
username = user_info.get("username", "?")
global_name = user_info.get("global_name") or username
console.print(
f"[green]✓[/green] Valid token from [cyan]{valid_source}[/cyan]: {masked}"
)
console.print(
f" Logged in as: [bold]{global_name}[/bold] (@{username})"
)
if save:
env_path = save_token_to_env(valid_token)
console.print(f"[green]✓[/green] Saved to {env_path}")
else:
console.print(
"\n[dim]Run with --save to auto-save to .env[/dim]"
)
@cli.command("status")
@structured_output_options
def status(as_json: bool, as_yaml: bool):
"""Check if Discord token is valid."""
import sys
import httpx
from ..config import get_token
from ..exceptions import NotAuthenticatedError
try:
token = get_token()
except NotAuthenticatedError as e:
if emit_structured(
error_payload("not_authenticated", str(e)),
as_json=as_json,
as_yaml=as_yaml,
):
sys.exit(1)
console.print(f"[red]✗[/red] {e}")
sys.exit(1)
try:
resp = httpx.get(
"https://discord.com/api/v10/users/@me",
headers={"Authorization": token},
timeout=10.0,
)
if resp.status_code == 200:
user = resp.json()
payload = success_payload(
{
"authenticated": True,
"user": _discord_user_payload(user),
}
)
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
sys.exit(0)
name = user.get("global_name") or user.get("username", "?")
console.print(f"[green]✓[/green] Authenticated as [bold]{name}[/bold] (@{user.get('username')})")
sys.exit(0)
else:
if emit_structured(
error_payload(
"invalid_token",
f"Token invalid (HTTP {resp.status_code})",
details={"status_code": resp.status_code},
),
as_json=as_json,
as_yaml=as_yaml,
):
sys.exit(1)
console.print(f"[red]✗[/red] Token invalid (HTTP {resp.status_code})")
sys.exit(1)
except Exception as e:
if emit_structured(
error_payload("connection_error", str(e)),
as_json=as_json,
as_yaml=as_yaml,
):
sys.exit(1)
console.print(f"[red]✗[/red] Connection error: {e}")
sys.exit(1)
@cli.command("whoami")
@structured_output_options
def whoami(as_json: bool, as_yaml: bool):
"""Show detailed profile of the current user."""
import asyncio
from ..client import get_client, get_me
async def _run():
async with get_client() as client:
return await get_me(client)
try:
info = asyncio.run(_run())
except Exception as exc:
if emit_structured(error_payload("auth_error", str(exc)), as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None
raise click.ClickException(str(exc)) from exc
if emit_structured(success_payload({"user": _discord_user_payload(info)}), as_json=as_json, as_yaml=as_yaml):
return
premium_names = {0: "None", 1: "Nitro Classic", 2: "Nitro", 3: "Nitro Basic"}
table = Table(title="Discord Profile", show_header=False)
table.add_column("Field", style="bold")
table.add_column("Value")
table.add_row("Username", f"@{info['username']}")
if info.get("global_name"):
table.add_row("Display Name", info["global_name"])
table.add_row("ID", info["id"])
if info.get("email"):
table.add_row("Email", info["email"])
if info.get("phone"):
table.add_row("Phone", info["phone"])
table.add_row("MFA", "✓" if info.get("mfa_enabled") else "✗")
table.add_row("Nitro", premium_names.get(info.get("premium_type", 0), "?"))
table.add_row("Created", info.get("created_at", "?")[:10])
console.print(table)
# Register sub-groups
cli.add_command(discord_group, "dc")
# Register top-level query commands
for name, cmd in query_group.commands.items():
cli.add_command(cmd, name)
# Register top-level data commands
for name, cmd in data_group.commands.items():
cli.add_command(cmd, name)
"""Query commands — search, stats, today, top, timeline."""
from collections import defaultdict
import click
from rich.console import Console
from rich.table import Table
from ._channels import resolve_channel_id_or_raise
from ._output import emit_structured, structured_output_options
from ..db import MessageDB
console = Console(stderr=True)
@click.group("query", invoke_without_command=True)
def query_group():
"""Query and analysis commands (registered at top-level)."""
pass
@query_group.command("search")
@click.argument("keyword")
@click.option("-c", "--channel", help="Filter by channel name")
@click.option("-n", "--limit", default=50, help="Max results")
@structured_output_options
def search(keyword: str, channel: str | None, limit: int, as_json: bool, as_yaml: bool):
"""Search stored messages by KEYWORD."""
with MessageDB() as db:
channel_id = resolve_channel_id_or_raise(db, channel) if channel else None
results = db.search(keyword, channel_id=channel_id, limit=limit)
if results and emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No messages found.[/yellow]")
return
for msg in results:
ts = (msg.get("timestamp") or "")[:19]
sender = msg.get("sender_name") or "Unknown"
ch_name = msg.get("channel_name") or ""
content = (msg.get("content") or "")[:200]
console.print(
f"[dim]{ts}[/dim] [cyan]#{ch_name}[/cyan] | "
f"[bold]{sender}[/bold]: {content}"
)
console.print(f"\n[dim]Found {len(results)} messages[/dim]")
@query_group.command("recent")
@click.option("-c", "--channel", help="Filter by channel name")
@click.option("--hours", type=int, help="Only show messages from last N hours")
@click.option("-n", "--limit", default=50, help="Show last N messages")
@structured_output_options
def recent(channel: str | None, hours: int | None, limit: int, as_json: bool, as_yaml: bool):
"""Show the most recent stored messages."""
with MessageDB() as db:
channel_id = resolve_channel_id_or_raise(db, channel) if channel else None
results = db.get_latest(channel_id=channel_id, hours=hours, limit=limit)
if results and emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No recent messages found.[/yellow]")
return
show_channel = channel_id is None
for msg in results:
ts = (msg.get("timestamp") or "")[:19]
sender = msg.get("sender_name") or "Unknown"
ch_name = msg.get("channel_name") or ""
content = (msg.get("content") or "")[:200].replace("\n", " ")
prefix = f"[cyan]#{ch_name}[/cyan] | " if show_channel and ch_name else ""
console.print(f"[dim]{ts}[/dim] {prefix}[bold]{sender}[/bold]: {content}")
console.print(f"\n[dim]Showing {len(results)} recent messages[/dim]")
@query_group.command("stats")
@structured_output_options
def stats(as_json: bool, as_yaml: bool):
"""Show message statistics per channel."""
with MessageDB() as db:
channels = db.get_channels()
total = db.count()
payload = {"total": total, "channels": channels}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title=f"Message Stats (Total: {total})")
table.add_column("Channel ID", style="dim")
table.add_column("Channel", style="bold")
table.add_column("Guild", style="cyan")
table.add_column("Messages", justify="right")
table.add_column("First", style="dim")
table.add_column("Last", style="dim")
for c in channels:
ch_id = str(c["channel_id"])
table.add_row(
ch_id[-6:] + "…" if len(ch_id) > 6 else ch_id,
f"#{c['channel_name']}" if c["channel_name"] else "—",
c.get("guild_name") or "—",
str(c["msg_count"]),
(c["first_msg"] or "")[:10],
(c["last_msg"] or "")[:10],
)
console.print(table)
@query_group.command("today")
@click.option("-c", "--channel", help="Filter by channel name")
@structured_output_options
def today(channel: str | None, as_json: bool, as_yaml: bool):
"""Show today's messages, grouped by channel."""
with MessageDB() as db:
channel_id = resolve_channel_id_or_raise(db, channel) if channel else None
msgs = db.get_today(channel_id=channel_id)
if msgs and emit_structured(msgs, as_json=as_json, as_yaml=as_yaml):
return
if not msgs:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No messages today.[/yellow]")
return
grouped: dict[str, list[dict]] = defaultdict(list)
for m in msgs:
key = f"#{m.get('channel_name') or 'unknown'}"
if m.get("guild_name"):
key = f"{m['guild_name']} > {key}"
grouped[key].append(m)
for ch_label, ch_msgs in sorted(grouped.items(), key=lambda x: -len(x[1])):
console.print(f"\n[bold cyan]═══ {ch_label} ({len(ch_msgs)} msgs) ═══[/bold cyan]")
for m in ch_msgs:
ts = (m.get("timestamp") or "")[11:19]
sender = m.get("sender_name") or "Unknown"
content = (m.get("content") or "")[:200].replace("\n", " ")
console.print(f" [dim]{ts}[/dim] [bold]{sender[:15]}[/bold]: {content}")
console.print(f"\n[green]Total: {len(msgs)} messages today[/green]")
@query_group.command("top")
@click.option("-c", "--channel", help="Filter by channel name")
@click.option("--hours", type=int, help="Only count messages within N hours")
@click.option("-n", "--limit", default=20, help="Top N senders")
@structured_output_options
def top(channel: str | None, hours: int | None, limit: int, as_json: bool, as_yaml: bool):
"""Show most active senders."""
with MessageDB() as db:
channel_id = resolve_channel_id_or_raise(db, channel) if channel else None
results = db.top_senders(channel_id=channel_id, hours=hours, limit=limit)
if results and emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No sender data found.[/yellow]")
return
table = Table(title="Top Senders")
table.add_column("#", style="dim", justify="right")
table.add_column("Sender", style="bold")
table.add_column("Messages", justify="right")
table.add_column("First", style="dim")
table.add_column("Last", style="dim")
for i, r in enumerate(results, 1):
table.add_row(
str(i),
r["sender_name"],
str(r["msg_count"]),
(r["first_msg"] or "")[:10],
(r["last_msg"] or "")[:10],
)
console.print(table)
@query_group.command("timeline")
@click.option("-c", "--channel", help="Filter by channel name")
@click.option("--hours", type=int, help="Only show last N hours")
@click.option("--by", "granularity", type=click.Choice(["day", "hour"]), default="day")
@structured_output_options
def timeline(channel: str | None, hours: int | None, granularity: str, as_json: bool, as_yaml: bool):
"""Show message activity over time as a bar chart."""
with MessageDB() as db:
channel_id = resolve_channel_id_or_raise(db, channel) if channel else None
results = db.timeline(channel_id=channel_id, hours=hours, granularity=granularity)
if results and emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No timeline data.[/yellow]")
return
max_count = max(r["msg_count"] for r in results)
bar_width = 40
for r in results:
period = r["period"]
count = r["msg_count"]
bar_len = int(count / max_count * bar_width) if max_count > 0 else 0
bar = "█" * bar_len
console.print(f"[dim]{period}[/dim] {bar} [bold]{count}[/bold]")
"""Discord REST API v10 client using httpx."""
from __future__ import annotations
import asyncio
import random
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, AsyncGenerator
import httpx
from .config import API_BASE, CHROME_UA, SEC_CH_UA, get_token
from .exceptions import RateLimitError
# Discord epoch: 2015-01-01T00:00:00Z
DISCORD_EPOCH = 1420070400000
def snowflake_to_datetime(snowflake: int | str) -> datetime:
"""Convert a Discord snowflake ID to a UTC datetime."""
ms = (int(snowflake) >> 22) + DISCORD_EPOCH
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
def datetime_to_snowflake(dt: datetime) -> int:
"""Convert a datetime to a Discord snowflake ID (for use as 'after' param)."""
ms = int(dt.timestamp() * 1000) - DISCORD_EPOCH
return ms << 22
@asynccontextmanager
async def get_client() -> AsyncGenerator[httpx.AsyncClient, None]:
"""Async context manager for an authenticated httpx client."""
token = get_token()
async with httpx.AsyncClient(
base_url=API_BASE,
headers={
"Authorization": token,
"Content-Type": "application/json",
"User-Agent": CHROME_UA,
"sec-ch-ua": SEC_CH_UA,
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
},
timeout=30.0,
) as client:
yield client
async def _handle_rate_limit(response: httpx.Response) -> None:
"""Sleep if we hit a rate limit, with jitter."""
if response.status_code == 429:
data = response.json()
retry_after = data.get("retry_after", 1.0)
await asyncio.sleep(retry_after + random.uniform(0.5, 2.0))
elif remaining := response.headers.get("X-RateLimit-Remaining"):
if int(remaining) == 0:
reset_after = float(response.headers.get("X-RateLimit-Reset-After", "1.0"))
await asyncio.sleep(reset_after + random.uniform(0.2, 1.0))
async def _get(client: httpx.AsyncClient, path: str, **params: Any) -> Any:
"""GET request with rate limit handling and retry."""
for attempt in range(3):
response = await client.get(path, params=params)
if response.status_code == 429:
await _handle_rate_limit(response)
continue
await _handle_rate_limit(response)
response.raise_for_status()
return response.json()
raise RateLimitError(f"Rate limited after 3 retries: {path}")
async def list_guilds(client: httpx.AsyncClient) -> list[dict]:
"""List all guilds (servers) the user has joined."""
data = await _get(client, "/users/@me/guilds")
return [
{
"id": g["id"],
"name": g["name"],
"icon": g.get("icon"),
"owner": g.get("owner", False),
}
for g in data
]
async def resolve_guild_id(client: httpx.AsyncClient, guild: str) -> str | None:
"""Resolve a guild name or ID string to a guild ID.
Returns the guild ID if found, or None if not.
"""
if guild.isdigit():
return guild
guilds = await list_guilds(client)
match = next(
(g for g in guilds if guild.lower() in g["name"].lower()),
None,
)
return match["id"] if match else None
async def list_channels(client: httpx.AsyncClient, guild_id: str) -> list[dict]:
"""List all text channels in a guild."""
data = await _get(client, f"/guilds/{guild_id}/channels")
# type 0 = text channel, 5 = announcement, 15 = forum
text_types = {0, 5, 15}
results = []
for ch in data:
if ch.get("type") in text_types:
results.append(
{
"id": ch["id"],
"name": ch["name"],
"type": ch.get("type", 0),
"position": ch.get("position", 0),
"parent_id": ch.get("parent_id"),
"topic": ch.get("topic"),
}
)
return sorted(results, key=lambda x: x["position"])
async def fetch_messages(
client: httpx.AsyncClient,
channel_id: str,
*,
limit: int = 1000,
after: str | None = None,
before: str | None = None,
) -> list[dict]:
"""Fetch messages from a channel, handling pagination.
Discord returns max 100 messages per request, so we paginate.
Two modes:
- after mode: fetch messages newer than `after` ID (incremental sync)
- before/default mode: fetch messages older-ward (history fetch)
"""
all_messages: list[dict] = []
remaining = limit
use_after = after is not None
while remaining > 0:
batch_limit = min(remaining, 100)
params: dict[str, Any] = {"limit": batch_limit}
if use_after:
params["after"] = after
elif before:
params["before"] = before
data = await _get(client, f"/channels/{channel_id}/messages", **params)
if not data:
break
for msg in data:
all_messages.append(_parse_message(msg, channel_id))
remaining -= len(data)
if len(data) < batch_limit:
break
# Discord always returns messages newest-first.
if use_after:
# 'after' mode: move cursor to the newest message we've seen
after = data[0]["id"]
else:
# 'before'/default mode: move cursor to the oldest message we've seen
before = data[-1]["id"]
# Small delay with jitter to be nice
await asyncio.sleep(random.uniform(0.3, 1.0))
# Sort by timestamp ascending
all_messages.sort(key=lambda m: m["msg_id"])
return all_messages
def _parse_message(msg: dict, channel_id: str) -> dict:
"""Parse a raw Discord message into our standard format."""
author = msg.get("author", {})
ts_str = msg.get("timestamp", "")
timestamp = datetime.fromisoformat(ts_str) if ts_str else datetime.now(timezone.utc)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
# Build content: message text + any attachment URLs
content_parts = []
if msg.get("content"):
content_parts.append(msg["content"])
for att in msg.get("attachments", []):
content_parts.append(f"[attachment: {att.get('filename', 'file')}]")
for embed in msg.get("embeds", []):
if title := embed.get("title"):
content_parts.append(f"[embed: {title}]")
return {
"msg_id": msg["id"],
"channel_id": channel_id,
"sender_id": author.get("id"),
"sender_name": author.get("global_name") or author.get("username") or "Unknown",
"content": "\n".join(content_parts),
"timestamp": timestamp,
}
async def get_guild_info(client: httpx.AsyncClient, guild_id: str) -> dict | None:
"""Get detailed guild info."""
try:
data = await _get(client, f"/guilds/{guild_id}", with_counts="true")
return {
"id": data["id"],
"name": data["name"],
"description": data.get("description"),
"member_count": data.get("approximate_member_count"),
"online_count": data.get("approximate_presence_count"),
}
except (httpx.HTTPError, KeyError, ValueError):
return None
async def get_me(client: httpx.AsyncClient) -> dict:
"""Get current user info."""
data = await _get(client, "/users/@me")
created_at = snowflake_to_datetime(data["id"])
return {
"id": data["id"],
"username": data.get("username", "?"),
"global_name": data.get("global_name"),
"email": data.get("email"),
"phone": data.get("phone"),
"mfa_enabled": data.get("mfa_enabled", False),
"premium_type": data.get("premium_type", 0),
"created_at": created_at.isoformat(),
}
async def get_user(client: httpx.AsyncClient, user_id: str) -> dict | None:
"""Get a user's profile."""
try:
data = await _get(client, f"/users/{user_id}")
return {
"id": data["id"],
"username": data.get("username"),
"global_name": data.get("global_name"),
"bot": data.get("bot", False),
"created_at": snowflake_to_datetime(data["id"]).isoformat(),
}
except (httpx.HTTPError, KeyError, ValueError):
return None
async def search_guild_messages(
client: httpx.AsyncClient,
guild_id: str,
query: str,
*,
channel_id: str | None = None,
limit: int = 25,
) -> list[dict]:
"""Search messages in a guild using Discord's built-in search."""
params: dict[str, Any] = {"content": query}
if channel_id:
params["channel_id"] = channel_id
data = await _get(client, f"/guilds/{guild_id}/messages/search", **params)
results = []
for group in data.get("messages", []):
for msg in group:
if msg.get("hit"):
results.append(_parse_message(msg, msg.get("channel_id", "")))
return results[:limit]
async def list_members(
client: httpx.AsyncClient,
guild_id: str,
limit: int = 100,
) -> list[dict]:
"""List members of a guild."""
data = await _get(client, f"/guilds/{guild_id}/members", limit=min(limit, 1000))
return [
{
"id": m["user"]["id"],
"username": m["user"].get("username"),
"global_name": m["user"].get("global_name"),
"nick": m.get("nick"),
"joined_at": m.get("joined_at"),
"bot": m["user"].get("bot", False),
}
for m in data
]
"""Configuration management - loads from .env or environment variables."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
def _load_env() -> None:
"""Load .env from cwd first, then fall back to the source checkout."""
for candidate in (Path.cwd() / ".env", _PROJECT_ROOT / ".env"):
if candidate.is_file():
load_dotenv(candidate)
return
def _default_data_home() -> Path:
"""Return a platform-appropriate base directory for application data."""
if raw := os.environ.get("XDG_DATA_HOME", ""):
return Path(raw).expanduser()
home = Path.home()
if sys.platform == "darwin":
return home / "Library" / "Application Support"
if os.name == "nt":
local_appdata = os.environ.get("LOCALAPPDATA", "")
if local_appdata:
return Path(local_appdata).expanduser()
return home / "AppData" / "Local"
return home / ".local" / "share"
def _resolve_env_path(raw: str) -> Path:
"""Resolve user-provided paths relative to the current working directory."""
path = Path(raw).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path
_load_env()
APP_NAME = "discord-cli"
API_BASE = "https://discord.com/api/v10"
CHROME_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/133.0.0.0 Safari/537.36"
)
SEC_CH_UA = '"Chromium";v="133", "Not(A:Brand";v="99", "Google Chrome";v="133"'
def get_token() -> str:
val = os.environ.get("DISCORD_TOKEN", "")
if not val:
from .exceptions import NotAuthenticatedError
raise NotAuthenticatedError(
"DISCORD_TOKEN not set. Get it from browser DevTools → "
"Network tab → any Discord request → Authorization header."
)
return val
def get_data_dir() -> Path:
"""Return data directory, create if not exists."""
raw = os.environ.get("DATA_DIR", "")
if raw:
d = _resolve_env_path(raw)
else:
d = _default_data_home() / APP_NAME
d.mkdir(parents=True, exist_ok=True)
return d
def get_db_path() -> Path:
raw = os.environ.get("DB_PATH", "")
if raw:
p = _resolve_env_path(raw)
else:
p = get_data_dir() / "messages.db"
p.parent.mkdir(parents=True, exist_ok=True)
return p
"""SQLite database for storing Discord chat messages."""
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import datetime, timedelta, timezone, tzinfo
from pathlib import Path
from typing import Any
from .config import get_db_path
log = logging.getLogger(__name__)
_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL DEFAULT 'discord',
guild_id TEXT,
guild_name TEXT,
channel_id TEXT NOT NULL,
channel_name TEXT,
msg_id TEXT NOT NULL,
sender_id TEXT,
sender_name TEXT,
content TEXT,
timestamp TEXT NOT NULL,
raw_json TEXT,
UNIQUE(platform, channel_id, msg_id)
);
"""
_CREATE_INDEX = """
CREATE INDEX IF NOT EXISTS idx_messages_channel_ts ON messages(channel_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(content);
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_name);
CREATE INDEX IF NOT EXISTS idx_messages_guild ON messages(guild_id);
"""
class ChannelResolutionError(ValueError):
"""Base error for channel lookup failures."""
class ChannelNotFoundError(ChannelResolutionError):
"""Raised when a channel cannot be found in local storage."""
def __init__(self, query: str):
super().__init__(f"Channel '{query}' not found in database.")
class AmbiguousChannelError(ChannelResolutionError):
"""Raised when a channel query matches multiple stored channels."""
def __init__(self, query: str, matches: list[dict]):
preview = ", ".join(_format_channel_match(match) for match in matches[:5])
if len(matches) > 5:
preview += ", ..."
super().__init__(
f"Channel '{query}' is ambiguous. Matches: {preview}. "
"Use a more specific name or a channel ID."
)
self.matches = matches
def _format_channel_match(channel: dict) -> str:
"""Format a channel record for error messages."""
name = channel.get("channel_name") or channel.get("channel_id") or "unknown"
guild = channel.get("guild_name")
if guild:
return f"{guild} > #{name} ({channel['channel_id']})"
return f"#{name} ({channel['channel_id']})"
class MessageDB:
"""SQLite message store with context manager support."""
def __init__(self, db_path: Path | str | None = None):
if db_path is None:
self.db_path = get_db_path()
else:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(str(self.db_path))
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.executescript(_CREATE_TABLE + _CREATE_INDEX)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
def insert_batch(self, messages: list[dict], platform: str = "discord") -> int:
"""Batch insert messages. Returns rows actually inserted (excluding dupes)."""
if not messages:
return 0
rows = [
(
platform,
m.get("guild_id"),
m.get("guild_name"),
m["channel_id"],
m.get("channel_name"),
m["msg_id"],
m.get("sender_id"),
m.get("sender_name"),
m.get("content"),
m["timestamp"].isoformat() if isinstance(m["timestamp"], datetime) else m["timestamp"],
json.dumps(m["raw_json"], ensure_ascii=False) if m.get("raw_json") else None,
)
for m in messages
]
try:
before = self.conn.total_changes
self.conn.executemany(
"""INSERT OR IGNORE INTO messages
(platform, guild_id, guild_name, channel_id, channel_name,
msg_id, sender_id, sender_name, content, timestamp, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
rows,
)
self.conn.commit()
return self.conn.total_changes - before
except sqlite3.Error as e:
log.warning("insert_batch failed: %s", e)
return 0
def resolve_channel_id(self, channel_str: str) -> str | None:
"""Resolve a channel string (name or ID) to a database channel_id.
Returns None if not found in the database.
"""
try:
return self.resolve_channel(channel_str)["channel_id"]
except ChannelResolutionError:
return None
def find_channels(self, channel_str: str) -> list[dict]:
"""Find candidate stored channels by ID, exact name, or partial name."""
channels = self.get_channels()
query = channel_str.lower()
exact_id_matches = [c for c in channels if c["channel_id"] == channel_str]
if exact_id_matches:
return exact_id_matches
exact_name_matches = [
c
for c in channels
if c.get("channel_name") and c["channel_name"].lower() == query
]
if exact_name_matches:
return exact_name_matches
return [
c
for c in channels
if c.get("channel_name") and query in c["channel_name"].lower()
]
def resolve_channel(self, channel_str: str) -> dict:
"""Resolve a stored channel, rejecting missing or ambiguous matches."""
matches = self.find_channels(channel_str)
if not matches:
raise ChannelNotFoundError(channel_str)
if len(matches) > 1:
raise AmbiguousChannelError(channel_str, matches)
return matches[0]
def search(
self,
keyword: str,
channel_id: str | None = None,
limit: int = 50,
) -> list[dict]:
"""Search messages by keyword."""
query = "SELECT * FROM messages WHERE content LIKE ?"
params: list[Any] = [f"%{keyword}%"]
if channel_id:
query += " AND channel_id = ?"
params.append(channel_id)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
def get_recent(
self,
channel_id: str | None = None,
hours: int | None = 24,
limit: int = 500,
) -> list[dict]:
"""Get recent messages in chronological order."""
if hours is not None:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
query = "SELECT * FROM messages WHERE timestamp >= ?"
params: list[Any] = [cutoff]
else:
query = "SELECT * FROM messages WHERE 1=1"
params = []
if channel_id:
query += " AND channel_id = ?"
params.append(channel_id)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(query, params).fetchall()
return [dict(r) for r in reversed(rows)]
def get_latest(
self,
channel_id: str | None = None,
hours: int | None = None,
limit: int = 50,
) -> list[dict]:
"""Get the most recent messages, returned in chronological order."""
query = "SELECT * FROM messages WHERE 1=1"
params: list[Any] = []
if channel_id:
query += " AND channel_id = ?"
params.append(channel_id)
if hours is not None:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
query += " AND timestamp >= ?"
params.append(cutoff)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(query, params).fetchall()
return [dict(r) for r in reversed(rows)]
def get_today(
self,
channel_id: str | None = None,
tz: tzinfo | None = None,
limit: int = 5000,
now: datetime | None = None,
) -> list[dict]:
"""Get today's messages (in local timezone)."""
now_utc = now.astimezone(timezone.utc) if now else datetime.now(timezone.utc)
local_tz = tz or datetime.now().astimezone().tzinfo or timezone.utc
today_local = now_utc.astimezone(local_tz).replace(hour=0, minute=0, second=0, microsecond=0)
cutoff_utc = today_local.astimezone(timezone.utc).isoformat()
query = "SELECT * FROM messages WHERE timestamp >= ?"
params: list[Any] = [cutoff_utc]
if channel_id:
query += " AND channel_id = ?"
params.append(channel_id)
query += " ORDER BY channel_name, timestamp ASC LIMIT ?"
params.append(limit)
rows = self.conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
def get_channels(self) -> list[dict]:
"""Get all known channels with message counts."""
rows = self.conn.execute(
"""SELECT channel_id, channel_name, guild_id, guild_name,
COUNT(*) as msg_count,
MIN(timestamp) as first_msg, MAX(timestamp) as last_msg
FROM messages
GROUP BY channel_id
ORDER BY msg_count DESC"""
).fetchall()
return [dict(r) for r in rows]
def get_last_msg_id(self, channel_id: str) -> str | None:
"""Get the latest msg_id for a channel, used for incremental sync."""
row = self.conn.execute(
"SELECT MAX(msg_id) FROM messages WHERE channel_id = ?", (channel_id,)
).fetchone()
return row[0] if row and row[0] is not None else None
def count(self, channel_id: str | None = None) -> int:
if channel_id:
row = self.conn.execute(
"SELECT COUNT(*) FROM messages WHERE channel_id = ?", (channel_id,)
).fetchone()
else:
row = self.conn.execute("SELECT COUNT(*) FROM messages").fetchone()
return row[0]
def delete_channel(self, channel_id: str) -> int:
"""Delete all messages for a channel. Returns number of deleted rows."""
cursor = self.conn.execute(
"DELETE FROM messages WHERE channel_id = ?", (channel_id,)
)
self.conn.commit()
return cursor.rowcount
def top_senders(
self,
channel_id: str | None = None,
hours: int | None = None,
limit: int = 20,
) -> list[dict]:
"""Get most active senders."""
conditions = ["sender_name IS NOT NULL"]
params: list[Any] = []
if channel_id:
conditions.append("channel_id = ?")
params.append(channel_id)
if hours:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
conditions.append("timestamp >= ?")
params.append(cutoff)
where = " AND ".join(conditions)
rows = self.conn.execute(
f"""SELECT COALESCE(MAX(sender_name), 'Unknown') as sender_name,
sender_id,
COUNT(*) as msg_count,
MIN(timestamp) as first_msg, MAX(timestamp) as last_msg
FROM messages WHERE {where}
GROUP BY COALESCE(sender_id, sender_name)
ORDER BY msg_count DESC
LIMIT ?""",
params + [limit],
).fetchall()
return [dict(r) for r in rows]
def timeline(
self,
channel_id: str | None = None,
hours: int | None = None,
granularity: str = "day",
) -> list[dict]:
"""Get message count grouped by time period."""
if granularity == "hour":
time_expr = "substr(timestamp, 1, 13)" # YYYY-MM-DDTHH
else:
time_expr = "substr(timestamp, 1, 10)" # YYYY-MM-DD
conditions = ["1=1"]
params: list[Any] = []
if channel_id:
conditions.append("channel_id = ?")
params.append(channel_id)
if hours:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
conditions.append("timestamp >= ?")
params.append(cutoff)
where = " AND ".join(conditions)
rows = self.conn.execute(
f"""SELECT {time_expr} as period, COUNT(*) as msg_count
FROM messages WHERE {where}
GROUP BY period
ORDER BY period ASC""",
params,
).fetchall()
return [dict(r) for r in rows]
def close(self):
self.conn.close()
"""Structured exception hierarchy for discord-cli."""
from __future__ import annotations
class DiscordCLIError(Exception):
"""Base exception for discord-cli."""
class NotAuthenticatedError(DiscordCLIError):
"""Raised when the Discord token is missing or invalid."""
class RateLimitError(DiscordCLIError):
"""Raised when the Discord API rate limit is exhausted after retries."""
class GuildNotFoundError(DiscordCLIError):
"""Raised when a guild cannot be resolved by name or ID."""
class NetworkError(DiscordCLIError):
"""Raised on connection or HTTP errors."""
from __future__ import annotations
from datetime import datetime, timezone
import os
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
os.environ.setdefault("OUTPUT", "rich")
from discord_cli.db import MessageDB
@pytest.fixture
def seeded_db(tmp_path, monkeypatch) -> MessageDB:
db_path = tmp_path / "messages.db"
monkeypatch.setenv("DB_PATH", str(db_path))
rows = [
{
"msg_id": "100",
"channel_id": "c-general",
"channel_name": "general",
"guild_id": "g-1",
"guild_name": "Dev",
"sender_id": "u-1",
"sender_name": "Alice",
"content": "first message",
"timestamp": datetime(2026, 3, 10, 1, 0, tzinfo=timezone.utc),
},
{
"msg_id": "101",
"channel_id": "c-general",
"channel_name": "general",
"guild_id": "g-1",
"guild_name": "Dev",
"sender_id": "u-2",
"sender_name": "Bob",
"content": "second message",
"timestamp": datetime(2026, 3, 10, 2, 0, tzinfo=timezone.utc),
},
{
"msg_id": "102",
"channel_id": "c-random",
"channel_name": "random",
"guild_id": "g-1",
"guild_name": "Dev",
"sender_id": "u-1",
"sender_name": "Alice",
"content": "third message",
"timestamp": datetime(2026, 3, 10, 3, 0, tzinfo=timezone.utc),
},
]
with MessageDB() as db:
db.insert_batch(rows)
db = MessageDB()
try:
yield db
finally:
db.close()
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from discord_cli.db import MessageDB
def test_get_latest_returns_latest_messages_in_chronological_order(seeded_db: MessageDB):
messages = seeded_db.get_latest(limit=2)
assert [m["msg_id"] for m in messages] == ["101", "102"]
assert [m["content"] for m in messages] == ["second message", "third message"]
def test_get_today_uses_provided_timezone(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "messages.db"))
with MessageDB() as db:
db.insert_batch(
[
{
"msg_id": "1",
"channel_id": "c-1",
"channel_name": "general",
"sender_name": "Alice",
"content": "before local midnight",
"timestamp": datetime(2026, 3, 9, 15, 59, tzinfo=timezone.utc),
},
{
"msg_id": "2",
"channel_id": "c-1",
"channel_name": "general",
"sender_name": "Bob",
"content": "after local midnight",
"timestamp": datetime(2026, 3, 9, 16, 1, tzinfo=timezone.utc),
},
]
)
messages = db.get_today(
tz=timezone(timedelta(hours=8)),
now=datetime(2026, 3, 10, 4, 0, tzinfo=timezone.utc),
)
assert [m["msg_id"] for m in messages] == ["2"]
def test_get_recent_returns_latest_messages_in_chronological_order(seeded_db: MessageDB):
messages = seeded_db.get_recent(hours=None, limit=2)
assert [m["msg_id"] for m in messages] == ["101", "102"]
def test_top_senders_groups_by_sender_id_not_name(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "messages.db"))
with MessageDB() as db:
db.insert_batch(
[
{
"msg_id": "1",
"channel_id": "c-1",
"channel_name": "general",
"sender_id": "u-1",
"sender_name": "Alex",
"content": "hello",
"timestamp": datetime(2026, 3, 10, 1, 0, tzinfo=timezone.utc),
},
{
"msg_id": "2",
"channel_id": "c-1",
"channel_name": "general",
"sender_id": "u-2",
"sender_name": "Alex",
"content": "world",
"timestamp": datetime(2026, 3, 10, 2, 0, tzinfo=timezone.utc),
},
]
)
top = db.top_senders(limit=10)
assert len(top) == 2
assert {row["sender_id"] for row in top} == {"u-1", "u-2"}
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from click.testing import CliRunner
from discord_cli.cli import discord_cmds
from discord_cli.cli.main import cli
from discord_cli.db import MessageDB
def test_tail_fetch_once_enriches_and_stores_messages(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "messages.db"))
async def fake_fetch_messages(client, channel_id, *, limit, after=None, before=None):
assert channel_id == "c-1"
assert after == "100"
assert limit == 10
return [
{
"msg_id": "101",
"channel_id": "c-1",
"sender_id": "u-1",
"sender_name": "Alice",
"content": "hello live",
"timestamp": datetime(2026, 3, 10, 4, 0, tzinfo=timezone.utc),
}
]
monkeypatch.setattr(discord_cmds, "fetch_messages", fake_fetch_messages)
with MessageDB() as db:
messages, last_id, inserted = asyncio.run(
discord_cmds._tail_fetch_once(
object(),
db,
"c-1",
after="100",
fetch_limit=10,
context={"channel_name": "general", "guild_name": "Dev", "guild_id": "g-1"},
store=True,
)
)
stored = db.get_latest(channel_id="c-1", limit=10)
assert [m["msg_id"] for m in messages] == ["101"]
assert last_id == "101"
assert inserted == 1
assert stored[0]["channel_name"] == "general"
assert stored[0]["guild_name"] == "Dev"
def test_dc_tail_once_prints_snapshot(seeded_db: MessageDB, monkeypatch):
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"id": "c-general", "name": "general", "guild_id": "g-1"}
class FakeClient:
async def get(self, path):
if path == "/channels/c-general":
return FakeResponse()
raise AssertionError(path)
@asynccontextmanager
async def fake_get_client():
yield FakeClient()
async def fake_get_guild_info(client, guild_id):
assert guild_id == "g-1"
return {"id": "g-1", "name": "Dev"}
async def fake_fetch_messages(client, channel_id, *, limit, after=None, before=None):
assert channel_id == "c-general"
assert limit == 2
return [
{
"msg_id": "201",
"channel_id": "c-general",
"sender_id": "u-1",
"sender_name": "Alice",
"content": "live one",
"timestamp": datetime(2026, 3, 10, 5, 0, tzinfo=timezone.utc),
},
{
"msg_id": "202",
"channel_id": "c-general",
"sender_id": "u-2",
"sender_name": "Bob",
"content": "live two",
"timestamp": datetime(2026, 3, 10, 5, 1, tzinfo=timezone.utc),
},
]
monkeypatch.setattr(discord_cmds, "get_client", fake_get_client)
monkeypatch.setattr(discord_cmds, "get_guild_info", fake_get_guild_info)
monkeypatch.setattr(discord_cmds, "fetch_messages", fake_fetch_messages)
runner = CliRunner()
result = runner.invoke(cli, ["dc", "tail", "c-general", "--once", "-n", "2", "--no-store"])
assert result.exit_code == 0
assert "live one" in result.output
assert "live two" in result.output
assert "Watching" not in result.output
def test_dc_sync_all_discovers_channels_from_api(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "messages.db"))
class FakeClient:
pass
@asynccontextmanager
async def fake_get_client():
yield FakeClient()
async def fake_list_guilds(client):
assert isinstance(client, FakeClient)
return [{"id": "g-1", "name": "Dev", "owner": False}]
async def fake_list_channels(client, guild_id):
assert guild_id == "g-1"
return [{"id": "c-1", "name": "general", "type": 0, "position": 1}]
async def fake_fetch_messages(client, channel_id, *, limit, after=None, before=None):
assert channel_id == "c-1"
assert after is None
return [
{
"msg_id": "101",
"channel_id": "c-1",
"sender_id": "u-1",
"sender_name": "Alice",
"content": "bootstrapped",
"timestamp": datetime(2026, 3, 10, 5, 0, tzinfo=timezone.utc),
}
]
monkeypatch.setattr(discord_cmds, "get_client", fake_get_client)
monkeypatch.setattr(discord_cmds, "list_guilds", fake_list_guilds)
monkeypatch.setattr(discord_cmds, "list_channels", fake_list_channels)
monkeypatch.setattr(discord_cmds, "fetch_messages", fake_fetch_messages)
runner = CliRunner()
result = runner.invoke(cli, ["dc", "sync-all", "-n", "50"])
assert result.exit_code == 0
assert "Discovered 1 channels across 1 guilds" in result.output
assert "+1" in result.output
with MessageDB() as db:
stored = db.get_latest(channel_id="c-1", limit=10)
assert [msg["msg_id"] for msg in stored] == ["101"]
assert stored[0]["guild_name"] == "Dev"
assert stored[0]["channel_name"] == "general"
from __future__ import annotations
import json
from click.testing import CliRunner
import yaml
from discord_cli.cli.main import cli
from discord_cli.db import MessageDB
def test_recent_command_shows_latest_messages(seeded_db: MessageDB):
runner = CliRunner()
result = runner.invoke(cli, ["recent", "-n", "2"])
assert result.exit_code == 0
assert "second message" in result.output
assert "third message" in result.output
assert "first message" not in result.output
def test_recent_command_supports_json(seeded_db: MessageDB):
runner = CliRunner()
result = runner.invoke(cli, ["recent", "-c", "general", "-n", "2", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
rows = payload["data"]
assert [row["msg_id"] for row in rows] == ["100", "101"]
assert all(row["channel_name"] == "general" for row in rows)
def test_recent_command_auto_yaml_when_stdout_is_not_tty(seeded_db: MessageDB, monkeypatch):
monkeypatch.setenv("OUTPUT", "auto")
runner = CliRunner()
result = runner.invoke(cli, ["recent", "-c", "general", "-n", "2"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
rows = payload["data"]
assert [row["msg_id"] for row in rows] == ["100", "101"]
def test_timeline_command_supports_json(seeded_db: MessageDB):
runner = CliRunner()
result = runner.invoke(cli, ["timeline", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
rows = payload["data"]
assert rows
assert rows[0]["period"] == "2026-03-10"
def test_recent_command_rejects_ambiguous_channel(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "messages.db"))
with MessageDB() as db:
db.insert_batch(
[
{
"msg_id": "1",
"channel_id": "c-general",
"channel_name": "general",
"guild_id": "g-1",
"guild_name": "Dev",
"sender_id": "u-1",
"sender_name": "Alice",
"content": "hello",
"timestamp": "2026-03-10T01:00:00+00:00",
},
{
"msg_id": "2",
"channel_id": "c-general-chat",
"channel_name": "general-chat",
"guild_id": "g-1",
"guild_name": "Dev",
"sender_id": "u-2",
"sender_name": "Bob",
"content": "world",
"timestamp": "2026-03-10T02:00:00+00:00",
},
]
)
runner = CliRunner()
result = runner.invoke(cli, ["recent", "-c", "gen"])
assert result.exit_code != 0
assert "ambiguous" in result.output
def test_recent_command_rejects_ambiguous_channel_yaml(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "messages.db"))
monkeypatch.setenv("OUTPUT", "auto")
with MessageDB() as db:
db.insert_batch(
[
{
"msg_id": "1",
"channel_id": "c-general",
"channel_name": "general",
"guild_id": "g-1",
"guild_name": "Dev",
"sender_id": "u-1",
"sender_name": "Alice",
"content": "hello",
"timestamp": "2026-03-10T01:00:00+00:00",
},
{
"msg_id": "2",
"channel_id": "c-general-chat",
"channel_name": "general-chat",
"guild_id": "g-1",
"guild_name": "Dev",
"sender_id": "u-2",
"sender_name": "Bob",
"content": "world",
"timestamp": "2026-03-10T02:00:00+00:00",
},
]
)
runner = CliRunner()
result = runner.invoke(cli, ["recent", "-c", "gen", "--yaml"])
assert result.exit_code != 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is False
assert payload["error"]["code"] == "channel_resolution_error"
def test_status_auto_yaml_when_stdout_is_not_tty(monkeypatch):
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setenv("DISCORD_TOKEN", "token")
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"id": "u-1", "username": "alice", "global_name": "Alice"}
monkeypatch.setattr("httpx.get", lambda *args, **kwargs: FakeResponse())
runner = CliRunner()
result = runner.invoke(cli, ["status"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["schema_version"] == "1"
assert payload["data"]["authenticated"] is True
assert payload["data"]["user"]["username"] == "alice"
def test_whoami_auto_yaml_when_stdout_is_not_tty(monkeypatch):
monkeypatch.setenv("OUTPUT", "auto")
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def fake_get_me(client):
return {
"id": "u-1",
"username": "alice",
"global_name": "Alice",
"created_at": "2026-03-10T00:00:00+00:00",
}
monkeypatch.setattr("discord_cli.client.get_client", lambda: FakeClient())
monkeypatch.setattr("discord_cli.client.get_me", fake_get_me)
runner = CliRunner()
result = runner.invoke(cli, ["whoami"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["user"]["username"] == "alice"