optojr_slack_send tool: post one Slack message as the @OptoJr bot through the hosted relay

Configuration files for Bash, Starship, Homebrew, Ghostty, Herdr, Neovim, OpenCode, Claude Code, Codex, and Pi. GNU Stow manages symlinks from stow/* into $HOME. Ghostty, Herdr, Neovim, Pi, OpenCode, Claude Code, and Codex use Tokyo Night with MonoLisaCode 14 pt.
night style with transparent editor, sidebar, float, statusline, and tab-fill surfaces.MonoLisaCode at 14 pt with explicit regular, italic, bold, and bold-italic styles.Symbols Nerd Font fallback. See plans/theme-font-glyph-followups.md.old/ and are not restored or rethemed.| Tool | Minimum Version | Notes |
|---|---|---|
| Neovim | >= 0.11.0 | Required for mason-lspconfig v2 and vim.lsp.config() |
| Git | >= 2.19.0 | Required for lazy.nvim partial clones |
| GNU Stow | >= 2.4.0 | Symlink manager for tracked dotfiles |
| Ghostty | Latest | Uses macos-option-as-alt syntax |
| Herdr | Latest | Agent-aware terminal workspace manager |
| Node.js | LTS | For LSP servers via Mason |
| tree-sitter-cli | >= 0.26.1 | Required for nvim-treesitter main branch parser compilation |
| lazygit | >= 0.40 | Required for snacks.lazygit keymap (<leader>gg) |
| ripgrep | >= 13.0 | Required for nvim-spectre search backend |
| gnu-sed | Latest | Recommended on macOS for nvim-spectre replace engine (brew install gnu-sed) |
| imagemagick | >= 7.0 | Required for snacks.image preview support |
spotify-visualizer is a standalone TypeScript command managed by the bin Stow package. It renders a procedural terminal dot matrix based on the website music visualizer colors, then uses Spotify only for the current track, artist, play state, and track-specific animation seed.
Setup:
# 1. Create or reuse a Spotify developer app.
# 2. Add this redirect URI to that app:
# http://127.0.0.1:8974/callback
# 3. Export the client id before launching the visualizer:
export SPOTIFY_CLIENT_ID=your_spotify_client_id
spotify-visualizer
The command stores OAuth tokens under ~/.cache/dotfiles/spotify-visualizer/. Run it in any Herdr pane or tab when you want a dedicated visualizer screen.
Controls:
| Key | Action |
|---|---|
Space | Toggle Spotify play or pause |
n | Skip to the next track |
p | Skip to the previous track |
s | Toggle shuffle |
r | Cycle repeat off, context, and current track |
q / Ctrl-C | Quit and restore the terminal |
The visualizer shows this key legend in the header. Short notices, such as pressing a playback key before Spotify has an active track, replace the legend for about 3 seconds.
Shuffle and repeat state use compact status tokens in the header. Shuffle uses [S:-] when inactive and yellow [S:*] when active. Repeat uses gray [R:-] when inactive, red [R:all] for repeat context, and red [R:1] for repeat current track.
If Spotify returns 401 after scopes change, remove the cached token and authorize again:
rm ~/.cache/dotfiles/spotify-visualizer/tokens.json
spotify-visualizer
The TypeSafe skill defines when and how to request a Jev judgment. Pi 1.0 uses native Codemode classifiers; it no longer installs a custom TypeSafe SDK adapter. OpenCode retains its typesafe_evaluate adapter under stow/opencode/.config/opencode/plugins/typesafe-ai/. Both send the supplied state and questions to TypeSafe and return typed judgments.
In Pi Codemode:
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
if (!jev) throw new Error("Jev classifier is unavailable");
const result = await models.classify(jev, {
state: { message: "A customer asks to cancel today" },
questions: {
urgent: {
type: "bool",
instructions: "Does this need a reply today?",
criteria: { true: "Needs a reply today", false: "Can wait" },
},
},
});
if (result.stopReason !== "stop") throw new Error(result.errorMessage || "Jev classification failed");
return result.answers.urgent;
Native questions use choice, score, or bool; a bool answer supplies a probability, not a boolean decision. Specialized Jev MCP tools remain available for screening, verification, and gates. Do not include credentials, secrets, or unrelated private data.
OpenCode pins the TypeSafe tool in its Code Mode catalog and adds tool invocation guidance to outgoing contexts where the catalog lists it. Shared global rules and the Jev skill own judgment policy; the adapter explains only the Code Mode calling convention and result shape. Use exact search, parsing, arithmetic, and tests for deterministic facts. Inside execute, the tool returns a validated object with answers, model, and usage; no JSON.parse is needed. For example, when the catalog lists tools.typesafe_evaluate:
const result = await tools.typesafe_evaluate({
state: "A customer asks to cancel today",
noul_questions: [{ id: "urgent", instructions: "Does this need a reply today?" }],
});
return result.answers.urgent;
The Noul answer is { type: "noul", noul: probability }, not a boolean. Choice and Score answers include confidence and probability distributions. Missing or malformed provider answers fail explicitly rather than returning an empty success. Tool results retain readable text and model/token metadata for other consumers. Judgments advise; they do not replace permission checks or prove correctness.
Keep TYPESAFE_API_KEY as machine-local state in ~/.config/bash/local.bash:
export TYPESAFE_API_KEY="YOUR_API_KEY"
scripts/bootstrap.sh installs the OpenCode adapters' pinned runtime dependencies. Pi's native classifier needs no separate SDK installation. Apply and reload live configuration only after reviewing and approving the tracked changes.
The opencode Stow package owns the tracked sources under stow/opencode/.config/opencode/.
| Tracked source | Purpose |
|---|---|
opencode.jsonc | Model defaults, permissions, MCP servers, providers, skills, and global instructions |
cli.json | Tokyo Night, TUI layout, permission handling, and keybindings |
commands/ | Slash commands such as /lg |
plugins/typesafe-ai/ | TypeSafe Jev tool |
plugins/tui-conveniences/ | /copy-all, /restart, /update, skill-load confirmations, and the Git status footer |
plugins/request-logger/ | Opt-in private HTTP request capture |
New sessions use openai/gpt-6.1-sol-fast with medium reasoning effort and low response verbosity. Its pinned limits match the running ChatGPT catalog checked on 2026-10-04: 400,000 context, 272,000 input, and 128,000 output tokens. This catalog alias sends gpt-6.1-sol with the priority service tier. The limits preserve the current compaction budget rather than assuming the public API's larger window applies to this connection. The TUI hides the session sidebar and persistent tab strip. The TUI also provides Pi-style navigation shortcuts.
OpenCode can use the current user's filesystem, processes, and network without a permission prompt. Review the tracked configuration before you apply it.
REF_API_KEY, EXA_API_KEY, and TYPESAFE_API_KEY are machine-local state. Do not put real credentials in tracked sources.
Apply only the OpenCode Stow package with:
cd ~/dotfiles
./scripts/stow.sh apply opencode
Bootstrap installs plugin dependencies automatically. Restart OpenCode after an apply.
The last local plugin is request-logger, disabled by default with options.enabled: false. After reviewing the tracked changes and approving live activation, enable it through the plugin's options in opencode.jsonc:
{
"package": "../../code/personal/dotfiles/stow/opencode/.config/opencode/plugins/request-logger",
"options": {
"enabled": true,
"maxFiles": 100
}
}
This is an entry in the existing plugins array, not a replacement configuration. Bootstrap installs its dependencies along with the other local plugins. The logger runs in the background service, so a flag on a new CLI process is not used to enable it. It writes to ~/.local/state/opencode/requests, or an absolute options.directory, with directory mode 0700 and file mode 0600. It refuses a symlink at the log directory and retains the newest 100 logger-owned files by default; maxFiles must be a positive integer. Retention limits file count, not total bytes, and leaves unrelated files alone.
Each JSON file records the timestamp, session, agent, catalog model, request kind, raw body string, and byte counts for instructions, input, and tools when those fields exist. The body shows the actual wire model and provider options; the catalog model can be an alias. Byte counts are not token counts, and the instruction count excludes system messages embedded in the input array. For non-UTF-8 bodies, bodyBase64 preserves the original bytes. The original request remains unchanged and readable; logging failures emit a generic server warning without error details and do not block dispatch.
Logs contain full prompts, tool schemas, and file contents, including any secrets already present in the body. The logger deliberately omits URLs and request headers, but does not redact bodies because that would hide what was sent. Keep logs outside version control, review them before sharing, and disable logging when the investigation ends. Capture covers HTTP requests for primary turns, compaction, titles, transient generation, and retries, not responses or WebSocket frames. Keep the logger after request-mutating plugins; hooks registered later can still change the request after capture. Automatic updates, MCP package updates, and automatic compaction remain unchanged.
OpenCode's codex-chrome MCP server uses codex-control-chrome-mcp to control the existing Chrome profile through the Codex Chrome extension. Unlike the isolated chrome-devtools server, it can use existing signed-in tabs and capture screenshots of localhost apps. It grants access to page contents and browser actions; the community bridge does not enforce per-site permissions.
After approving live configuration changes, install the bridge and register it for Google Chrome only:
npm install -g codex-control-chrome-mcp@1.4.1
codex-control-chrome-mcp install-native-host --browser chrome
codex-control-chrome-mcp status --browser chrome
The tracked MCP entry starts codex-control-chrome-mcp from PATH; on Linux the binary is absent, so only that server fails to start. The installer backs up Chrome's existing native-host manifest and records its original host for proxy mode. Reload the Codex Chrome extension after installation, then reconnect codex-chrome through OpenCode's /mcps menu or restart OpenCode. Automatic registration repair remains enabled: after a Codex update restores its own host registration, starting the bridge re-registers it, and the extension may need another reload.
To restore the previous Chrome native-host registration, disconnect codex-chrome in OpenCode and run:
codex-control-chrome-mcp uninstall-native-host --browser chrome
Also remove the codex-chrome MCP entry if you no longer want OpenCode to start the bridge.
OpenAI GPT-5 models and the verified GPT-6 Astra, GPT-6 Sol, and GPT-6.1 Sol models support low, medium, and high output verbosity through the Responses API. The tracked configs currently use low.
gpt-6-astra, gpt-6-sol, and gpt-6.1-sol IDs using openai-responses or openai-codex-responses in stow/pi/.pi/agent/extensions/gpt-verbosity.ts. Change the verbosity: "low" value, then run /reload in Pi.model_verbosity in stow/codex/.codex/config.toml. Change the value, then restart Codex.textVerbosity per provider and model in stow/opencode/.config/opencode/opencode.jsonc. Update each GPT-5 model entry you use under provider.openai.models or provider.opencode.models, then restart OpenCode.For example, an OpenCode model override uses this shape:
"providers": {
"openai": {
"models": {
"gpt-5.6-sol-fast": {
"settings": {
"textVerbosity": "low",
},
},
},
},
}
Run claude-log from your regular shell instead of claude to start Claude Code through an opt-in local Anthropic request logger:
claude-log
The command starts a local proxy on a temporary loopback port, launches Claude Code against it, and stops the proxy when Claude exits. Each /v1/messages request is written under ~/.claude/logs/requests/ as readable Markdown plus the raw JSON payload. The Markdown includes request sizes, ranked tool schemas, redacted request headers, the full payload, and the streamed provider response. This logger is inspired by Matt Pocock's agent proxy. It does not capture direct MCP network traffic.
The directory and files use owner-only permissions. The logs can contain sensitive source code, prompts, and connected-service data. Review them before sharing, and remove them when finished:
rm -rf ~/.claude/logs/requests
Set CLAUDE_REQUEST_LOG_DIR to store logs somewhere else. Normal claude sessions do not write request logs.
Claude Code uses the Tokyo Night night theme from stow/claude/.claude/themes/tokyonight-night.json, taken from folke's tokyonight.nvim Claude Code extras. Claude Code watches ~/.claude/themes/, so theme edits apply to running sessions.
Mods live in stow/claude/.claude/mods/ and are not stowed. CLAUDE_CODE_PLUGIN_DIRS in settings.json loads them from the repository, and interactive sessions reload a mod when its files are saved.
optojr-slack registers optojr_slack_send, which posts as @OptoJr through the relay credentials in the macOS Keychain.skill-toast shows a toast when a skill loads.session-relaunch adds /update, which runs claude update and resumes the session on the new version, and /restart, which resumes the session on whatever version is installed. The relaunch comes from the claude function in stow/bash/.config/bash/functions.bash. Sessions started any other way print the claude --resume command instead of exiting.jev-pipeline registers mcp__jev-pipeline__run, which fetches a list from one MCP tool, optionally enriches each item with a second MCP call, and classifies or reranks every item with Jev. The items never enter the model's context: the reply holds counts and the top items, and the full results go to $TMPDIR/jev-pipeline-<session id>-<timestamp>.json.jev-coding adds a jev_decide reminder to the first Edit or Write of each turn, and records every edit the turn makes. When the agent tries to finish, Jev classifies each edit against the recent prompts as requested, scope creep, speculative, or leftover. A confidently flagged edit blocks the stop once with the list, so the agent reverts or justifies it; a Jev failure shows a toast and lets the turn finish.jev-screen runs jev_screen on every Exa and Ref fetch result, in 20,000-character chunks. A review or block verdict, or a failed screen, adds a warning the model reads after the result, and block also shows a toast.model-cost writes the session's cost per model to $TMPDIR/claude-model-cost-<session id>.json, and statusline.sh shows it after the context usage. The engine reports only a session total, so each response is charged the total's growth since the previous response.Add a new mod folder to CLAUDE_CODE_PLUGIN_DIRS to load it. Check a mod with claude plugin validate <folder> and claude plugin test <folder>.
Run pi-log from your regular shell instead of pi when you need to inspect the exact payload Pi sends to its model provider:
pi-log
The command enables the tracked request-logger.ts extension for that Pi process only. Each request is written as a readable Markdown file under ~/.pi/agent/logs/requests/, including a size audit, ranked tool schemas, the complete provider payload, the normalized assistant response, and response status metadata when the active provider exposes it. The directory and files use owner-only permissions. These logs can contain source code, prompts, tool results, Gmail, Slack, or Drive data, so do not commit or share them without reviewing the contents. Remove captured requests when finished:
rm -rf ~/.pi/agent/logs/requests
You can also run pi --request-log directly, or set PI_REQUEST_LOG_DIR to store logs somewhere else. Normal pi sessions do not write request logs.
# 1. Clone the repo
git clone https://github.com/manifoldfrs/dotfiles.git ~/dotfiles
# 2. Run the installer
cd ~/dotfiles
./scripts/bootstrap.sh
# 3. Fully quit and reopen your terminal
# 4. Verify Node.js works
node --version
# 5. Verify OpenCode 2 (installed by bootstrap)
opencode --version
Use the daily Stow wrapper when the repo is already on the machine and you just want the latest dotfiles applied.
# 1. Get the latest committed dotfiles
cd ~/dotfiles
git pull
# 2. Validate, then reapply all tracked shell/editor/terminal and Herdr config
./scripts/validate-dotfiles.sh
./scripts/stow.sh apply
# First time on this machine? Install Bash, Starship, and the supporting tools:
brew bundle --file=Brewfile
# or only the packaged shell stack:
# brew install bash starship zoxide fzf mise ripgrep fd gawk
# 3. Fully quit and reopen your terminal
# 4. Install/update declared Herdr plugins and reload a running server
./scripts/sync_herdr_plugins.sh
# 5. Verify the basics
node --version
herdr --version
Use ./scripts/bootstrap.sh instead when you also want to install or refresh Homebrew packages, Node.js, and Neovim plugins.
What this already handles for you:
Ctrl-a bindings, persistence, and agent-aware workspacesWhat ./scripts/bootstrap.sh additionally handles for you:
BrewfileWhat is still separate:
./mcp_setup.sh install for the optional Claude Desktop MCP config~/.claude.json, which stays local because it contains credentials and account-specific stateThe herdr Stow package also manages ~/.config/herdr/plugins.txt and ~/.config/plannotator-tui/config.toml. The default profile includes it. Stow only applies configuration, it does not install or update plugins.
# Existing machines: install the terminal review tool, then sync plugins.
# Requires herdr >= 0.8.0, Bun, and jq on PATH.
brew tap plannotator/tap
# On Homebrew versions that support trust:
# brew trust plannotator/tap
brew install plannotator/tap/plannotator-tui
./scripts/stow.sh apply
./scripts/sync_herdr_plugins.sh
The sync command installs or updates plannotator/herdr-annotate and paulbkim-dev/vim-herdr-navigation, checks the config, and reloads a running Herdr server. Plannotator TUI opens in a full-tab overlay. The agent sidebar prioritizes agents needing attention, uses distinct status symbols, and asks before closing workspaces. Pane-history persistence remains disabled.
| Shortcut | Action |
|---|---|
Ctrl-h/j/k/l | Navigate Neovim splits, then adjacent Herdr panes at the edge |
Ctrl-a a | Annotate selected terminal text |
Ctrl-a Shift-a | Copy annotations as agent context |
Ctrl-a m | Manage annotations |
Ctrl-a Shift-o | Review documents in the current folder |
Ctrl-a Shift-l | Review the agent's last reply |
Neovim visual <leader>a | Send the selection to Herdr Annotate |
Press Ctrl-a, release it, then press the shortcut's second key.
Launch Plannotator TUI directly from a shell:
plannotator-tui README.md # Review a file
plannotator-tui docs/ # Browse a folder
plannotator-tui herdr open . # Review this folder in a Herdr overlay
plannotator-tui herdr last # Annotate the agent's last reply in Herdr
plannotator-tui last --host pi # Review the latest Pi reply outside Herdr
plannotator-tui last --host claude # Review the latest Claude reply outside Herdr
The plannotator-tui commands open the terminal interface. The browser-based plannotator integration is installed alongside it.
Existing Ctrl-a o pane cycling and Ctrl-a z zoom bindings are unchanged. Global Ctrl-k and Ctrl-l navigation takes precedence over shell line deletion and screen clearing inside Herdr. Neovim outside Herdr retains ordinary split navigation. The annotation handoff uses a private temporary file that the plugin consumes and deletes.
In Ghostty on macOS, hold Shift + Cmd and click a link to open it, including inside Herdr. This bypasses application mouse capture and lets Ghostty handle the link. This gesture was verified in this setup, while Herdr's documented Ctrl-click gesture did not work. Keep mouse capture enabled to preserve Herdr's mouse UI. See Herdr's mouse guide.
Pi renders Markdown links as terminal hyperlinks, but relative targets such as docs/plan.md remain unresolved relative paths. The global Pi rules request absolute file:/// URLs for local files in chat and full https:// URLs for web links. File labels can still show readable repository-relative paths and line numbers. Line numbers are informational, not editor jump targets. Links written inside repository documentation remain relative for portability.
Shift-Cmd-click uses the system opener rather than the Herdr Annotate plugin. To review Markdown in Plannotator TUI, use Ctrl-a Shift-o or plannotator-tui herdr open <file.md>. Existing messages are not rewritten by the rule change. Start a new Pi session or use /reload to refresh the global instructions in an existing session.
cloak.nvim visually masks values in .env, .dev.vars, selected shell configuration files, and TOML token assignments. Use <leader>uC to toggle masking. This only affects display, not file contents, clipboard access, or agent access.:TSC runs the project's TypeScript compiler with --noEmit and opens errors in quickfix. Install TypeScript in the project first.ts-error-translator.nvim improves the readability of TypeScript diagnostics.:TwoslashQueriesEnable enables inline type queries and `:Thooks/register.ts 107 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 OPTOJR_KEYCHAIN_ACCOUNT,
5 OPTOJR_RELAY_KEYCHAIN_SERVICE,
6 optoJrRelayRequestBody,
7 parseOptoJrRelayCredentials,
8 parseOptoJrRelaySendResult,
9 type OptoJrRelayCredentials,
10 type OptoJrRelayMessage,
11} from './relay.ts'
12
13const TOOL_NAME = 'optojr_slack_send'
14const CREDENTIALS_UNAVAILABLE = 'OptoJr Slack relay credentials are unavailable'
15const RELAY_FAILED = 'OptoJr Slack relay request failed'
16
17const inputSchema = {
18 type: 'object',
19 additionalProperties: false,
20 required: ['channel_id', 'message'],
21 properties: {
22 channel_id: {
23 type: 'string',
24 pattern: '^[CDG][A-Z0-9]+$',
25 description: 'Slack channel, direct-message, or group-conversation ID',
26 },
27 message: {
28 type: 'string',
29 minLength: 1,
30 maxLength: 5_000,
31 description: 'Message text to post as @OptoJr',
32 },
33 thread_ts: {
34 type: 'string',
35 pattern: '^\\d+\\.\\d+$',
36 description: 'Parent message timestamp for a thread reply',
37 },
38 reply_broadcast: {
39 type: 'boolean',
40 description: 'Also show a thread reply in the channel',
41 },
42 },
43}
44
45async function readCredentials($: EngineInterface): Promise<OptoJrRelayCredentials | undefined> {
46 const { exitCode, stdout } = await $.process.run(
47 [
48 '/usr/bin/security',
49 'find-generic-password',
50 '-a',
51 OPTOJR_KEYCHAIN_ACCOUNT,
52 '-s',
53 OPTOJR_RELAY_KEYCHAIN_SERVICE,
54 '-w',
55 ],
56 { timeoutMs: 10_000 },
57 )
58 if (exitCode !== 0) return undefined
59 return parseOptoJrRelayCredentials(stdout.trim())
60}
61
62function failed(text: string) {
63 return { isError: true as const, result: text, text }
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 await $.tool.register({
69 name: TOOL_NAME,
70 description:
71 'Post one message as the @OptoJr Slack bot through the hosted relay. Resolve the channel ID and read thread context before calling this tool.',
72 inputSchema,
73 })
74
75 return next(e)
76 })
77
78 on('tool.call', { tool: 'mcp__optojr-slack__optojr_slack_send' }, async ($, e) => {
79 const credentials = await readCredentials($).catch(() => undefined)
80 if (!credentials) return failed(CREDENTIALS_UNAVAILABLE)
81
82 const response = await $.http
83 .fetch(new URL('/internal/slack/post-message', credentials.baseUrl).href, {
84 method: 'POST',
85 headers: {
86 Authorization: `Bearer ${credentials.authorizationToken}`,
87 'Content-Type': 'application/json; charset=utf-8',
88 },
89 body: optoJrRelayRequestBody(e as unknown as OptoJrRelayMessage),
90 })
91 .catch(() => undefined)
92 if (!response?.ok) return failed(RELAY_FAILED)
93
94 let body: unknown
95 try {
96 body = JSON.parse(response.text)
97 } catch {
98 return failed(RELAY_FAILED)
99 }
100 const sent = parseOptoJrRelaySendResult(body)
101 if (!sent) return failed(RELAY_FAILED)
102
103 const text = `Posted as @OptoJr in ${sent.channelId} at ${sent.messageTs}`
104 return { result: { ...sent, text } }
105 })
106}
107hooks/relay.ts 95 lines1export const OPTOJR_KEYCHAIN_ACCOUNT = 'optojr'
2export const OPTOJR_RELAY_KEYCHAIN_SERVICE = 'pi-optojr-slack-relay-credentials'
3
4export type OptoJrRelayCredentials = {
5 readonly baseUrl: URL
6 readonly authorizationToken: string
7}
8
9export type OptoJrRelayMessage = {
10 readonly channel_id: string
11 readonly message: string
12 readonly thread_ts?: string
13 readonly reply_broadcast?: boolean
14}
15
16export type OptoJrRelaySendResult = {
17 readonly channelId: string
18 readonly messageTs: string
19}
20
21function decodeHex(input: string): string {
22 const bytes = new Uint8Array(input.length / 2)
23 for (let index = 0; index < bytes.length; index += 1) {
24 bytes[index] = parseInt(input.slice(index * 2, index * 2 + 2), 16)
25 }
26 return new TextDecoder().decode(bytes)
27}
28
29// `security -w` prints the password as hex when it holds non-printable bytes.
30export function parseOptoJrRelayCredentials(input: string): OptoJrRelayCredentials | undefined {
31 const serialized = input.length % 2 === 0 && /^[a-f0-9]+$/i.test(input) ? decodeHex(input) : input
32 let parsed: unknown
33 try {
34 parsed = JSON.parse(serialized)
35 } catch {
36 return undefined
37 }
38 if (
39 typeof parsed !== 'object' ||
40 parsed === null ||
41 !('version' in parsed) ||
42 parsed.version !== 1 ||
43 !('baseUrl' in parsed) ||
44 typeof parsed.baseUrl !== 'string' ||
45 !('authorizationToken' in parsed) ||
46 typeof parsed.authorizationToken !== 'string' ||
47 parsed.authorizationToken.length < 32
48 ) {
49 return undefined
50 }
51 let baseUrl: URL
52 try {
53 baseUrl = new URL(parsed.baseUrl)
54 } catch {
55 return undefined
56 }
57 if (
58 baseUrl.protocol !== 'https:' ||
59 baseUrl.username !== '' ||
60 baseUrl.password !== '' ||
61 baseUrl.search !== '' ||
62 baseUrl.hash !== ''
63 ) {
64 return undefined
65 }
66 return { baseUrl, authorizationToken: parsed.authorizationToken }
67}
68
69export function parseOptoJrRelaySendResult(input: unknown): OptoJrRelaySendResult | undefined {
70 if (
71 typeof input !== 'object' ||
72 input === null ||
73 !('ok' in input) ||
74 input.ok !== true ||
75 !('channel_id' in input) ||
76 typeof input.channel_id !== 'string' ||
77 !/^[CDG][A-Z0-9]+$/.test(input.channel_id) ||
78 !('message_ts' in input) ||
79 typeof input.message_ts !== 'string' ||
80 !/^\d+\.\d+$/.test(input.message_ts)
81 ) {
82 return undefined
83 }
84 return { channelId: input.channel_id, messageTs: input.message_ts }
85}
86
87export function optoJrRelayRequestBody(message: OptoJrRelayMessage): string {
88 return JSON.stringify({
89 channel_id: message.channel_id,
90 message: message.message,
91 ...(message.thread_ts === undefined ? {} : { thread_ts: message.thread_ts }),
92 ...(message.reply_broadcast === undefined ? {} : { reply_broadcast: message.reply_broadcast }),
93 })
94}
95