Merge branch 'main' into docs/vps-standardization

This commit is contained in:
Wayne 2026-01-29 10:45:38 +08:00 committed by GitHub
commit bdcefe61de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1245 additions and 326 deletions

View File

@ -24,13 +24,26 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
// Labels prefixed with "r:" are auto-response triggers.
const rules = [
{
label: "skill-clawdhub",
label: "r: skill",
close: true,
message:
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. Were keeping the core lean on skills, so Im closing this out.",
},
{
label: "r: support",
close: true,
message:
"Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: third-party-extension",
close: true,
message:
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
},
];
const labelName = context.payload.label?.name;

View File

@ -66,18 +66,25 @@ Status: beta.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
- CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0.
- Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam.
### Breaking
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.
- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.
- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.
- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.
- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent.
- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang.
@ -100,6 +107,7 @@ Status: beta.
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn.
- Build: align memory-core peer dependency with lockfile.
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.

View File

@ -125,7 +125,7 @@ the prefix (use `""` to remove it).
- **DM policy**: `channels.whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
- Pairing: unknown senders get a pairing code (approve via `moltbot pairing approve whatsapp <code>`; codes expire after 1 hour).
- Open: requires `channels.whatsapp.allowFrom` to include `"*"`.
- Self messages are always allowed; “self-chat mode” still requires `channels.whatsapp.allowFrom` to include your own number.
- Your linked WhatsApp number is implicitly trusted, so self messages skip `channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks.
### Personal-number mode (fallback)
If you run Moltbot on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above).

View File

@ -20,5 +20,5 @@ moltbot security audit --deep
moltbot security audit --fix
```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.

View File

@ -11,7 +11,8 @@ Use `session.dmScope` to control how **direct messages** are grouped:
- `main` (default): all DMs share the main session for continuity.
- `per-peer`: isolate by sender id across channels.
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Moltbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@ -44,6 +45,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:dm:<peerId>` (accountId defaults to `default`).
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
@ -94,7 +96,7 @@ Send these as standalone messages so they register.
{
session: {
scope: "per-sender", // keep group keys separate
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
dmScope: "main", // DM continuity (set per-channel-peer/per-account-channel-peer for shared inboxes)
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},

View File

@ -2657,7 +2657,8 @@ Fields:
- `main`: all DMs share the main session for continuity.
- `per-peer`: isolate DMs by sender id across channels.
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- `per-account-channel-peer`: isolate DMs per account + channel + sender (recommended for multi-account inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).

View File

@ -199,7 +199,7 @@ By default, Moltbot routes **all DMs into the main session** so your assistant h
}
```
This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
This prevents cross-user context leakage while keeping group chats isolated. If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
## Allowlists (DM + groups) — terminology

View File

@ -4,9 +4,9 @@ read_when:
- You want privacy-focused inference in Moltbot
- You want Venice AI setup guidance
---
# Venice AI (Venius highlight)
# Venice AI (Venice highlight)
**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
**Venice** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.

View File

@ -1,101 +0,0 @@
---
name: bitwarden
description: Manage passwords and credentials via Bitwarden CLI (bw). Use for storing, retrieving, creating, or updating logins, credit cards, secure notes, and identities. Trigger when automating authentication, filling payment forms, or managing secrets programmatically.
---
# Bitwarden CLI
Full read/write vault access via `bw` command.
## Prerequisites
```bash
brew install bitwarden-cli
bw login <email> # one-time, prompts for master password
```
## Session Management
Bitwarden requires an unlocked session. Use the helper script:
```bash
source scripts/bw-session.sh <master_password>
# Sets BW_SESSION env var
```
Or manually:
```bash
export BW_SESSION=$(echo '<password>' | bw unlock --raw)
bw sync # always sync after unlock
```
## Common Operations
### Retrieve credentials
```bash
bw get password "Site Name"
bw get username "Site Name"
bw get item "Site Name" --pretty | jq '.login'
```
### Create login
```bash
bw get template item | jq '
.type = 1 |
.name = "Site Name" |
.login.username = "user@email.com" |
.login.password = "secret123" |
.login.uris = [{uri: "https://example.com"}]
' | bw encode | bw create item
```
### Create credit card
```bash
bw get template item | jq '
.type = 3 |
.name = "Card Name" |
.card.cardholderName = "John Doe" |
.card.brand = "Visa" |
.card.number = "4111111111111111" |
.card.expMonth = "12" |
.card.expYear = "2030" |
.card.code = "123"
' | bw encode | bw create item
```
### Get card for payment automation
```bash
bw get item "Card Name" | jq -r '.card | "\(.number) \(.expMonth)/\(.expYear) \(.code)"'
```
### List items
```bash
bw list items | jq -r '.[] | "\(.type)|\(.name)"'
# Types: 1=login, 2=note, 3=card, 4=identity
```
### Search
```bash
bw list items --search "vilaviniteca" | jq '.[0]'
```
## Item Types
| Type | Value | Use |
|------|-------|-----|
| Login | 1 | Website credentials |
| Secure Note | 2 | Freeform text |
| Card | 3 | Credit/debit cards |
| Identity | 4 | Personal info |
## References
- [templates.md](references/templates.md) — Full jq templates for all item types
- [Bitwarden CLI docs](https://bitwarden.com/help/cli/)
## Tips
1. **Always sync** after creating/editing items: `bw sync`
2. **Session expires** — re-unlock if you get auth errors
3. **Delete sensitive messages** after receiving credentials
4. **Card numbers** may not import from other managers (security restriction)

View File

@ -1,116 +0,0 @@
# Bitwarden Item Templates
jq patterns for creating vault items via CLI.
## Login (type=1)
```bash
bw get template item | jq '
.type = 1 |
.name = "Example Site" |
.notes = "Optional notes" |
.favorite = false |
.login.username = "user@example.com" |
.login.password = "secretPassword123" |
.login.totp = "otpauth://totp/..." |
.login.uris = [
{uri: "https://example.com", match: null},
{uri: "https://app.example.com", match: null}
]
' | bw encode | bw create item
```
## Credit Card (type=3)
```bash
bw get template item | jq '
.type = 3 |
.name = "Visa ending 1234" |
.notes = "Primary card" |
.card.cardholderName = "JOHN DOE" |
.card.brand = "Visa" |
.card.number = "4111111111111111" |
.card.expMonth = "12" |
.card.expYear = "2030" |
.card.code = "123"
' | bw encode | bw create item
```
**Brands:** Visa, Mastercard, Amex, Discover, Diners Club, JCB, Maestro, UnionPay, Other
## Secure Note (type=2)
```bash
bw get template item | jq '
.type = 2 |
.name = "API Keys" |
.notes = "OPENAI_KEY=sk-xxx\nANTHROPIC_KEY=sk-ant-xxx" |
.secureNote.type = 0
' | bw encode | bw create item
```
## Identity (type=4)
```bash
bw get template item | jq '
.type = 4 |
.name = "Personal Info" |
.identity.title = "Mr" |
.identity.firstName = "John" |
.identity.lastName = "Doe" |
.identity.email = "john@example.com" |
.identity.phone = "+34612345678" |
.identity.address1 = "123 Main St" |
.identity.city = "Barcelona" |
.identity.state = "Catalunya" |
.identity.postalCode = "08001" |
.identity.country = "ES"
' | bw encode | bw create item
```
## Edit Existing Item
```bash
# Get item, modify, update
bw get item <id> | jq '.login.password = "newPassword"' | bw encode | bw edit item <id>
```
## Custom Fields
```bash
bw get template item | jq '
.type = 1 |
.name = "With Custom Fields" |
.fields = [
{name: "Security Question", value: "Pet name", type: 0},
{name: "PIN", value: "1234", type: 1}
]
' | bw encode | bw create item
```
**Field types:** 0=text, 1=hidden, 2=boolean
## Retrieve Patterns
```bash
# Password only
bw get password "Site Name"
# Username only
bw get username "Site Name"
# Full login object
bw get item "Site Name" | jq '.login'
# Card number
bw get item "Card Name" | jq -r '.card.number'
# All card fields for form filling
bw get item "Card Name" | jq -r '.card | [.number, .expMonth, .expYear, .code] | @tsv'
# Search by URL
bw list items --url "example.com" | jq '.[0].login'
# List all cards
bw list items | jq '.[] | select(.type == 3) | .name'
```

View File

@ -1,33 +0,0 @@
#!/bin/bash
# Unlock Bitwarden vault and export session key
# Usage: source bw-session.sh <master_password>
# Or: source bw-session.sh (prompts for password)
set -e
if [ -n "$1" ]; then
MASTER_PW="$1"
else
read -sp "Bitwarden master password: " MASTER_PW
echo
fi
# Check if already logged in
if ! bw login --check &>/dev/null; then
echo "Not logged in. Run: bw login <email>"
return 1
fi
# Unlock and get session
export BW_SESSION=$(echo "$MASTER_PW" | bw unlock --raw 2>/dev/null)
if [ -z "$BW_SESSION" ]; then
echo "Failed to unlock vault"
return 1
fi
# Sync to get latest
bw sync &>/dev/null
echo "✓ Vault unlocked and synced"
echo "Session valid for this shell"

View File

@ -136,7 +136,7 @@ describe("models-config", () => {
}
>;
};
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1");

View File

@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => {
expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = fetch.mock.calls[0];
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm");
expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
expect(init?.method).toBe("POST");
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
"Bearer minimax-test",

View File

@ -1,3 +1,6 @@
export type { DirectoryConfigParams } from "./plugins/directory-config.js";
export type { ChannelDirectoryEntry } from "./plugins/types.js";
export type MessagingTargetKind = "user" | "channel";
export type MessagingTarget = {

View File

@ -71,6 +71,7 @@ const LOBSTER_ASCII = [
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
" ",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {

View File

@ -168,6 +168,11 @@ const entries: SubCliEntry[] = [
name: "pairing",
description: "Pairing helpers",
register: async (program) => {
// Initialize plugins before registering pairing CLI.
// The pairing CLI calls listPairingChannels() at registration time,
// which requires the plugin registry to be populated with channel plugins.
const { registerPluginCliCommands } = await import("../../plugins/cli.js");
registerPluginCliCommands(program, await loadConfig());
const mod = await import("../pairing-cli.js");
mod.registerPairingCli(program);
},

View File

@ -124,7 +124,7 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) {
if (dmScope === "main" && isMultiUserDm) {
warnings.push(
`- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" to isolate sessions.`,
`- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.`,
);
}
};

View File

@ -190,7 +190,7 @@ async function noteChannelPrimer(
"DM security: default is pairing; unknown DMs get a pairing code.",
`Approve with: ${formatCliCommand("moltbot pairing approve <channel> <code>")}`,
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.',
'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
"",
...channelLines,
@ -238,7 +238,7 @@ async function maybeConfigureDmPolicies(params: {
`Approve: ${formatCliCommand(`moltbot pairing approve ${policy.channel} <code>`)}`,
`Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`,
`Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`,
'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.',
'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
].join("\n"),
`${policy.label} DM access`,

View File

@ -69,7 +69,8 @@ export function printWizardHeader(runtime: RuntimeEnv) {
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
" 🦞 FRESH DAILY 🦞 ",
" ",
].join("\n");
runtime.log(header);
}

View File

@ -591,7 +591,7 @@ const FIELD_HELP: Record<string, string> = {
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
"session.dmScope":
'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).',
'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).',
"session.identityLinks":
"Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).",
"channels.telegram.configWrites":

View File

@ -3,7 +3,7 @@ import type { NormalizedChatType } from "../channels/chat-type.js";
export type ReplyMode = "text" | "command";
export type TypingMode = "never" | "instant" | "thinking" | "message";
export type SessionScope = "per-sender" | "global";
export type DmScope = "main" | "per-peer" | "per-channel-peer";
export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";

View File

@ -20,7 +20,12 @@ export const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
dmScope: z
.union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
.union([
z.literal("main"),
z.literal("per-peer"),
z.literal("per-channel-peer"),
z.literal("per-account-channel-peer"),
])
.optional(),
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(),

View File

@ -118,19 +118,26 @@ export async function parseAndResolveRecipient(
const accountInfo = resolveDiscordAccount({ cfg, accountId });
// First try to resolve using directory lookup (handles usernames)
const resolved = await resolveDiscordTarget(raw, {
cfg,
accountId: accountInfo.accountId,
});
const trimmed = raw.trim();
const parseOptions = {
ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
};
const resolved = await resolveDiscordTarget(
raw,
{
cfg,
accountId: accountInfo.accountId,
},
parseOptions,
);
if (resolved) {
return { kind: resolved.kind, id: resolved.id };
}
// Fallback to standard parsing (for channels, etc.)
const parsed = parseDiscordTarget(raw, {
ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`,
});
const parsed = parseDiscordTarget(raw, parseOptions);
if (!parsed) {
throw new Error("Recipient is required for Discord sends");

View File

@ -5,12 +5,11 @@ import {
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
type DirectoryConfigParams,
type ChannelDirectoryEntry,
} from "../channels/targets.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
import { resolveDiscordAccount } from "./accounts.js";
export type DiscordTargetKind = MessagingTargetKind;
@ -72,20 +71,26 @@ export function resolveDiscordChannelId(raw: string): string {
*
* @param raw - The username or raw target string (e.g., "john.doe")
* @param options - Directory configuration params (cfg, accountId, limit)
* @param parseOptions - Messaging target parsing options (defaults, ambiguity message)
* @returns Parsed MessagingTarget with user ID, or undefined if not found
*/
export async function resolveDiscordTarget(
raw: string,
options: DirectoryConfigParams,
parseOptions: DiscordTargetParseOptions = {},
): Promise<MessagingTarget | undefined> {
const trimmed = raw.trim();
if (!trimmed) return undefined;
// If already a known format, parse directly
const directParse = parseDiscordTarget(trimmed, options);
if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) {
const likelyUsername = isLikelyUsername(trimmed);
const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
const directParse = safeParseDiscordTarget(trimmed, parseOptions);
if (directParse && directParse.kind !== "channel" && !likelyUsername) {
return directParse;
}
if (!shouldLookup) {
return directParse ?? parseDiscordTarget(trimmed, parseOptions);
}
// Try to resolve as a username via directory lookup
try {
@ -101,13 +106,40 @@ export async function resolveDiscordTarget(
const userId = match.id.replace(/^user:/, "");
return buildMessagingTarget("user", userId, trimmed);
}
} catch (error) {
} catch {
// Directory lookup failed - fall through to parse as-is
// This preserves existing behavior for channel names
}
// Fallback to original parsing (for channels, etc.)
return parseDiscordTarget(trimmed, options);
return parseDiscordTarget(trimmed, parseOptions);
}
function safeParseDiscordTarget(
input: string,
options: DiscordTargetParseOptions,
): MessagingTarget | undefined {
try {
return parseDiscordTarget(input, options);
} catch {
return undefined;
}
}
function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean {
if (/^<@!?(\d+)>$/.test(input)) {
return true;
}
if (/^(user:|discord:)/.test(input)) {
return true;
}
if (input.startsWith("@")) {
return true;
}
if (/^\d+$/.test(input)) {
return options.defaultKind === "user";
}
return false;
}
/**

View File

@ -23,7 +23,7 @@ Automatically saves session context to your workspace memory when you issue the
When you run `/new` to start a fresh session:
1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable)
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
5. **Sends confirmation** - Notifies you with the file path
@ -57,7 +57,30 @@ The hook uses your configured LLM provider to generate slugs, so it works with a
## Configuration
No additional configuration required. The hook automatically:
The hook supports optional configuration:
| Option | Type | Default | Description |
| ---------- | ------ | ------- | --------------------------------------------------------------- |
| `messages` | number | 15 | Number of user/assistant messages to include in the memory file |
Example configuration:
```json
{
"hooks": {
"internal": {
"entries": {
"session-memory": {
"enabled": true,
"messages": 25
}
}
}
}
}
```
The hook automatically:
- Uses your workspace directory (`~/clawd` by default)
- Uses your configured LLM for slug generation

View File

@ -0,0 +1,379 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import handler from "./handler.js";
import { createHookEvent } from "../../hooks.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
/**
* Create a mock session JSONL file with various entry types
*/
function createMockSessionContent(
entries: Array<{ role: string; content: string } | { type: string }>,
): string {
return entries
.map((entry) => {
if ("role" in entry) {
return JSON.stringify({
type: "message",
message: {
role: entry.role,
content: entry.content,
},
});
}
// Non-message entry (tool call, system, etc.)
return JSON.stringify(entry);
})
.join("\n");
}
describe("session-memory hook", () => {
it("skips non-command events", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const event = createHookEvent("agent", "bootstrap", "agent:main:main", {
workspaceDir: tempDir,
});
await handler(event);
// Memory directory should not be created for non-command events
const memoryDir = path.join(tempDir, "memory");
await expect(fs.access(memoryDir)).rejects.toThrow();
});
it("skips commands other than new", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const event = createHookEvent("command", "help", "agent:main:main", {
workspaceDir: tempDir,
});
await handler(event);
// Memory directory should not be created for other commands
const memoryDir = path.join(tempDir, "memory");
await expect(fs.access(memoryDir)).rejects.toThrow();
});
it("creates memory file with session content on /new command", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create a mock session file with user/assistant messages
const sessionContent = createMockSessionContent([
{ role: "user", content: "Hello there" },
{ role: "assistant", content: "Hi! How can I help?" },
{ role: "user", content: "What is 2+2?" },
{ role: "assistant", content: "2+2 equals 4" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
// Memory file should be created
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
// Read the memory file and verify content
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
expect(memoryContent).toContain("user: Hello there");
expect(memoryContent).toContain("assistant: Hi! How can I help?");
expect(memoryContent).toContain("user: What is 2+2?");
expect(memoryContent).toContain("assistant: 2+2 equals 4");
});
it("filters out non-message entries (tool calls, system)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create session with mixed entry types
const sessionContent = createMockSessionContent([
{ role: "user", content: "Hello" },
{ type: "tool_use", tool: "search", input: "test" },
{ role: "assistant", content: "World" },
{ type: "tool_result", result: "found it" },
{ role: "user", content: "Thanks" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Only user/assistant messages should be present
expect(memoryContent).toContain("user: Hello");
expect(memoryContent).toContain("assistant: World");
expect(memoryContent).toContain("user: Thanks");
// Tool entries should not appear
expect(memoryContent).not.toContain("tool_use");
expect(memoryContent).not.toContain("tool_result");
expect(memoryContent).not.toContain("search");
});
it("filters out command messages starting with /", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionContent = createMockSessionContent([
{ role: "user", content: "/help" },
{ role: "assistant", content: "Here is help info" },
{ role: "user", content: "Normal message" },
{ role: "user", content: "/new" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Command messages should be filtered out
expect(memoryContent).not.toContain("/help");
expect(memoryContent).not.toContain("/new");
// Normal messages should be present
expect(memoryContent).toContain("assistant: Here is help info");
expect(memoryContent).toContain("user: Normal message");
});
it("respects custom messages config (limits to N messages)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create 10 messages
const entries = [];
for (let i = 1; i <= 10; i++) {
entries.push({ role: "user", content: `Message ${i}` });
}
const sessionContent = createMockSessionContent(entries);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
// Configure to only include last 3 messages
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, messages: 3 },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Only last 3 messages should be present
expect(memoryContent).not.toContain("user: Message 1\n");
expect(memoryContent).not.toContain("user: Message 7\n");
expect(memoryContent).toContain("user: Message 8");
expect(memoryContent).toContain("user: Message 9");
expect(memoryContent).toContain("user: Message 10");
});
it("filters messages before slicing (fix for #2681)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create session with many tool entries interspersed with messages
// This tests that we filter FIRST, then slice - not the other way around
const entries = [
{ role: "user", content: "First message" },
{ type: "tool_use", tool: "test1" },
{ type: "tool_result", result: "result1" },
{ role: "assistant", content: "Second message" },
{ type: "tool_use", tool: "test2" },
{ type: "tool_result", result: "result2" },
{ role: "user", content: "Third message" },
{ type: "tool_use", tool: "test3" },
{ type: "tool_result", result: "result3" },
{ role: "assistant", content: "Fourth message" },
];
const sessionContent = createMockSessionContent(entries);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
// Request 3 messages - if we sliced first, we'd only get 1-2 messages
// because the last 3 lines include tool entries
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, messages: 3 },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Should have exactly 3 user/assistant messages (the last 3)
expect(memoryContent).not.toContain("First message");
expect(memoryContent).toContain("user: Third message");
expect(memoryContent).toContain("assistant: Second message");
expect(memoryContent).toContain("assistant: Fourth message");
});
it("handles empty session files gracefully", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: "",
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
// Should not throw
await handler(event);
// Memory file should still be created with metadata
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
});
it("handles session files with fewer messages than requested", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Only 2 messages but requesting 15 (default)
const sessionContent = createMockSessionContent([
{ role: "user", content: "Only message 1" },
{ role: "assistant", content: "Only message 2" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Both messages should be included
expect(memoryContent).toContain("user: Only message 1");
expect(memoryContent).toContain("assistant: Only message 2");
});
});

View File

@ -8,25 +8,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import type { MoltbotConfig } from "../../../config/config.js";
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
import { resolveHookConfig } from "../../config.js";
import type { HookHandler } from "../../hooks.js";
/**
* Read recent messages from session file for slug generation
*/
async function getRecentSessionContent(sessionFilePath: string): Promise<string | null> {
async function getRecentSessionContent(
sessionFilePath: string,
messageCount: number = 15,
): Promise<string | null> {
try {
const content = await fs.readFile(sessionFilePath, "utf-8");
const lines = content.trim().split("\n");
// Get last 15 lines (recent conversation)
const recentLines = lines.slice(-15);
// Parse JSONL and extract messages
const messages: string[] = [];
for (const line of recentLines) {
// Parse JSONL and extract user/assistant messages first
const allMessages: string[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Session files have entries with type="message" containing a nested message object
@ -39,7 +41,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
? msg.content.find((c: any) => c.type === "text")?.text
: msg.content;
if (text && !text.startsWith("/")) {
messages.push(`${role}: ${text}`);
allMessages.push(`${role}: ${text}`);
}
}
}
@ -48,7 +50,9 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
}
}
return messages.join("\n");
// Then slice to get exactly messageCount messages
const recentMessages = allMessages.slice(-messageCount);
return recentMessages.join("\n");
} catch {
return null;
}
@ -93,12 +97,19 @@ const saveSessionToMemory: HookHandler = async (event) => {
const sessionFile = currentSessionFile || undefined;
// Read message count from hook config (default: 15)
const hookConfig = resolveHookConfig(cfg, "session-memory");
const messageCount =
typeof hookConfig?.messages === "number" && hookConfig.messages > 0
? hookConfig.messages
: 15;
let slug: string | null = null;
let sessionContent: string | null = null;
if (sessionFile) {
// Get recent conversation content
sessionContent = await getRecentSessionContent(sessionFile);
sessionContent = await getRecentSessionContent(sessionFile, messageCount);
console.log("[session-memory] sessionContent length:", sessionContent?.length || 0);
if (sessionContent && cfg) {
@ -106,10 +117,7 @@ const saveSessionToMemory: HookHandler = async (event) => {
// Dynamically import the LLM slug generator (avoids module caching issues)
// When compiled, handler is at dist/hooks/bundled/session-memory/handler.js
// Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js
const moltbotRoot = path.resolve(
path.dirname(import.meta.url.replace("file://", "")),
"../..",
);
const moltbotRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js");
const { generateSlugViaLLM } = await import(slugGenPath);

View File

@ -103,11 +103,13 @@ function buildBaseSessionKey(params: {
cfg: MoltbotConfig;
agentId: string;
channel: ChannelId;
accountId?: string | null;
peer: RoutePeer;
}): string {
return buildAgentSessionKey({
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
peer: params.peer,
dmScope: params.cfg.session?.dmScope ?? "main",
identityLinks: params.cfg.session?.identityLinks,
@ -200,6 +202,7 @@ async function resolveSlackSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "slack",
accountId: params.accountId,
peer,
});
const threadId = normalizeThreadId(params.threadId ?? params.replyToId);
@ -237,6 +240,7 @@ function resolveDiscordSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "discord",
accountId: params.accountId,
peer,
});
const explicitThreadId = normalizeThreadId(params.threadId);
@ -285,6 +289,7 @@ function resolveTelegramSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "telegram",
accountId: params.accountId,
peer,
});
return {
@ -312,6 +317,7 @@ function resolveWhatsAppSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "whatsapp",
accountId: params.accountId,
peer,
});
return {
@ -337,6 +343,7 @@ function resolveSignalSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "signal",
accountId: params.accountId,
peer,
});
return {
@ -371,6 +378,7 @@ function resolveSignalSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "signal",
accountId: params.accountId,
peer,
});
return {
@ -395,6 +403,7 @@ function resolveIMessageSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "imessage",
accountId: params.accountId,
peer,
});
return {
@ -419,6 +428,7 @@ function resolveIMessageSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "imessage",
accountId: params.accountId,
peer,
});
const toPrefix =
@ -450,6 +460,7 @@ function resolveMatrixSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "matrix",
accountId: params.accountId,
peer,
});
return {
@ -483,6 +494,7 @@ function resolveMSTeamsSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "msteams",
accountId: params.accountId,
peer,
});
return {
@ -517,6 +529,7 @@ function resolveMattermostSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "mattermost",
accountId: params.accountId,
peer,
});
const threadId = normalizeThreadId(params.replyToId ?? params.threadId);
@ -561,6 +574,7 @@ function resolveBlueBubblesSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "bluebubbles",
accountId: params.accountId,
peer,
});
return {
@ -586,6 +600,7 @@ function resolveNextcloudTalkSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "nextcloud-talk",
accountId: params.accountId,
peer,
});
return {
@ -612,6 +627,7 @@ function resolveZaloSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "zalo",
accountId: params.accountId,
peer,
});
return {
@ -639,6 +655,7 @@ function resolveZalouserSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "zalouser",
accountId: params.accountId,
peer,
});
return {
@ -661,6 +678,7 @@ function resolveNostrSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "nostr",
accountId: params.accountId,
peer,
});
return {
@ -719,6 +737,7 @@ function resolveTlonSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "tlon",
accountId: params.accountId,
peer,
});
return {

View File

@ -41,7 +41,7 @@ describe("applyMediaUnderstanding", () => {
mockedResolveApiKey.mockClear();
mockedFetchRemoteMedia.mockReset();
mockedFetchRemoteMedia.mockResolvedValue({
buffer: Buffer.from("audio-bytes"),
buffer: Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]),
contentType: "audio/ogg",
fileName: "note.ogg",
});
@ -51,7 +51,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "note.ogg");
await fs.writeFile(audioPath, "hello");
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
const ctx: MsgContext = {
Body: "<media:audio>",
@ -94,7 +94,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "note.ogg");
await fs.writeFile(audioPath, "hello");
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
const ctx: MsgContext = {
Body: "<media:audio> /capture status",
@ -176,7 +176,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "large.wav");
await fs.writeFile(audioPath, "0123456789");
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
const ctx: MsgContext = {
Body: "<media:audio>",
@ -211,7 +211,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "note.ogg");
await fs.writeFile(audioPath, "hello");
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
const ctx: MsgContext = {
Body: "<media:audio>",
@ -352,7 +352,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "fallback.ogg");
await fs.writeFile(audioPath, "hello");
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6]));
const ctx: MsgContext = {
Body: "<media:audio>",
@ -390,8 +390,8 @@ describe("applyMediaUnderstanding", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPathA = path.join(dir, "note-a.ogg");
const audioPathB = path.join(dir, "note-b.ogg");
await fs.writeFile(audioPathA, "hello");
await fs.writeFile(audioPathB, "world");
await fs.writeFile(audioPathA, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
await fs.writeFile(audioPathB, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
const ctx: MsgContext = {
Body: "<media:audio>",
@ -435,7 +435,7 @@ describe("applyMediaUnderstanding", () => {
const audioPath = path.join(dir, "note.ogg");
const videoPath = path.join(dir, "clip.mp4");
await fs.writeFile(imagePath, "image-bytes");
await fs.writeFile(audioPath, "audio-bytes");
await fs.writeFile(audioPath, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
await fs.writeFile(videoPath, "video-bytes");
const ctx: MsgContext = {
@ -487,4 +487,187 @@ describe("applyMediaUnderstanding", () => {
expect(ctx.CommandBody).toBe("audio ok");
expect(ctx.BodyForCommands).toBe("audio ok");
});
it("treats text-like audio attachments as CSV (comma wins over tabs)", async () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const csvPath = path.join(dir, "data.mp3");
const csvText = '"a","b"\t"c"\n"1","2"\t"3"';
const csvBuffer = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(csvText, "utf16le")]);
await fs.writeFile(csvPath, csvBuffer);
const ctx: MsgContext = {
Body: "<media:audio>",
MediaPath: csvPath,
MediaType: "audio/mpeg",
};
const cfg: MoltbotConfig = {
tools: {
media: {
audio: { enabled: false },
image: { enabled: false },
video: { enabled: false },
},
},
};
const result = await applyMediaUnderstanding({ ctx, cfg });
expect(result.appliedFile).toBe(true);
expect(ctx.Body).toContain('<file name="data.mp3" mime="text/csv">');
expect(ctx.Body).toContain('"a","b"\t"c"');
});
it("infers TSV when tabs are present without commas", async () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const tsvPath = path.join(dir, "report.mp3");
const tsvText = "a\tb\tc\n1\t2\t3";
await fs.writeFile(tsvPath, tsvText);
const ctx: MsgContext = {
Body: "<media:audio>",
MediaPath: tsvPath,
MediaType: "audio/mpeg",
};
const cfg: MoltbotConfig = {
tools: {
media: {
audio: { enabled: false },
image: { enabled: false },
video: { enabled: false },
},
},
};
const result = await applyMediaUnderstanding({ ctx, cfg });
expect(result.appliedFile).toBe(true);
expect(ctx.Body).toContain('<file name="report.mp3" mime="text/tab-separated-values">');
expect(ctx.Body).toContain("a\tb\tc");
});
it("escapes XML special characters in filenames to prevent injection", async () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
// Create file with XML special characters in the name (what filesystem allows)
// Note: The sanitizeFilename in store.ts would strip most dangerous chars,
// but we test that even if some slip through, they get escaped in output
const filePath = path.join(dir, "file<test>.txt");
await fs.writeFile(filePath, "safe content");
const ctx: MsgContext = {
Body: "<media:document>",
MediaPath: filePath,
MediaType: "text/plain",
};
const cfg: MoltbotConfig = {
tools: {
media: {
audio: { enabled: false },
image: { enabled: false },
video: { enabled: false },
},
},
};
const result = await applyMediaUnderstanding({ ctx, cfg });
expect(result.appliedFile).toBe(true);
// Verify XML special chars are escaped in the output
expect(ctx.Body).toContain("&lt;");
expect(ctx.Body).toContain("&gt;");
// The raw < and > should not appear unescaped in the name attribute
expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/);
});
it("normalizes MIME types to prevent attribute injection", async () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const filePath = path.join(dir, "data.txt");
await fs.writeFile(filePath, "test content");
const ctx: MsgContext = {
Body: "<media:document>",
MediaPath: filePath,
// Attempt to inject via MIME type with quotes - normalization should strip this
MediaType: 'text/plain" onclick="alert(1)',
};
const cfg: MoltbotConfig = {
tools: {
media: {
audio: { enabled: false },
image: { enabled: false },
video: { enabled: false },
},
},
};
const result = await applyMediaUnderstanding({ ctx, cfg });
expect(result.appliedFile).toBe(true);
// MIME normalization strips everything after first ; or " - verify injection is blocked
expect(ctx.Body).not.toContain("onclick=");
expect(ctx.Body).not.toContain("alert(1)");
// Verify the MIME type is normalized to just "text/plain"
expect(ctx.Body).toContain('mime="text/plain"');
});
it("handles path traversal attempts in filenames safely", async () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
// Even if a file somehow got a path-like name, it should be handled safely
const filePath = path.join(dir, "normal.txt");
await fs.writeFile(filePath, "legitimate content");
const ctx: MsgContext = {
Body: "<media:document>",
MediaPath: filePath,
MediaType: "text/plain",
};
const cfg: MoltbotConfig = {
tools: {
media: {
audio: { enabled: false },
image: { enabled: false },
video: { enabled: false },
},
},
};
const result = await applyMediaUnderstanding({ ctx, cfg });
expect(result.appliedFile).toBe(true);
// Verify the file was processed and output contains expected structure
expect(ctx.Body).toContain('<file name="');
expect(ctx.Body).toContain('mime="text/plain"');
expect(ctx.Body).toContain("legitimate content");
});
it("handles files with non-ASCII Unicode filenames", async () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const filePath = path.join(dir, "文档.txt");
await fs.writeFile(filePath, "中文内容");
const ctx: MsgContext = {
Body: "<media:document>",
MediaPath: filePath,
MediaType: "text/plain",
};
const cfg: MoltbotConfig = {
tools: {
media: {
audio: { enabled: false },
image: { enabled: false },
video: { enabled: false },
},
},
};
const result = await applyMediaUnderstanding({ ctx, cfg });
expect(result.appliedFile).toBe(true);
expect(ctx.Body).toContain("中文内容");
});
});

View File

@ -1,6 +1,22 @@
import path from "node:path";
import type { MoltbotConfig } from "../config/config.js";
import type { MsgContext } from "../auto-reply/templating.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import {
DEFAULT_INPUT_FILE_MAX_BYTES,
DEFAULT_INPUT_FILE_MAX_CHARS,
DEFAULT_INPUT_FILE_MIMES,
DEFAULT_INPUT_MAX_REDIRECTS,
DEFAULT_INPUT_PDF_MAX_PAGES,
DEFAULT_INPUT_PDF_MAX_PIXELS,
DEFAULT_INPUT_PDF_MIN_TEXT_CHARS,
DEFAULT_INPUT_TIMEOUT_MS,
extractFileContentFromSource,
normalizeMimeList,
normalizeMimeType,
} from "../media/input-files.js";
import {
extractMediaUserText,
formatAudioTranscripts,
@ -14,6 +30,7 @@ import type {
} from "./types.js";
import { runWithConcurrency } from "./concurrency.js";
import { resolveConcurrency } from "./resolve.js";
import { resolveAttachmentKind } from "./attachments.js";
import {
type ActiveMediaModel,
buildProviderRegistry,
@ -28,9 +45,279 @@ export type ApplyMediaUnderstandingResult = {
appliedImage: boolean;
appliedAudio: boolean;
appliedVideo: boolean;
appliedFile: boolean;
};
const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "video"];
const EXTRA_TEXT_MIMES = [
"application/xml",
"text/xml",
"application/x-yaml",
"text/yaml",
"application/yaml",
"application/javascript",
"text/javascript",
"text/tab-separated-values",
];
const TEXT_EXT_MIME = new Map<string, string>([
[".csv", "text/csv"],
[".tsv", "text/tab-separated-values"],
[".txt", "text/plain"],
[".md", "text/markdown"],
[".log", "text/plain"],
[".ini", "text/plain"],
[".cfg", "text/plain"],
[".conf", "text/plain"],
[".env", "text/plain"],
[".json", "application/json"],
[".yaml", "text/yaml"],
[".yml", "text/yaml"],
[".xml", "application/xml"],
]);
const XML_ESCAPE_MAP: Record<string, string> = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
'"': "&quot;",
"'": "&apos;",
};
/**
* Escapes special XML characters in attribute values to prevent injection.
*/
function xmlEscapeAttr(value: string): string {
return value.replace(/[<>&"']/g, (char) => XML_ESCAPE_MAP[char] ?? char);
}
function resolveFileLimits(cfg: MoltbotConfig) {
const files = cfg.gateway?.http?.endpoints?.responses?.files;
return {
allowUrl: files?.allowUrl ?? true,
allowedMimes: normalizeMimeList(files?.allowedMimes, DEFAULT_INPUT_FILE_MIMES),
maxBytes: files?.maxBytes ?? DEFAULT_INPUT_FILE_MAX_BYTES,
maxChars: files?.maxChars ?? DEFAULT_INPUT_FILE_MAX_CHARS,
maxRedirects: files?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS,
timeoutMs: files?.timeoutMs ?? DEFAULT_INPUT_TIMEOUT_MS,
pdf: {
maxPages: files?.pdf?.maxPages ?? DEFAULT_INPUT_PDF_MAX_PAGES,
maxPixels: files?.pdf?.maxPixels ?? DEFAULT_INPUT_PDF_MAX_PIXELS,
minTextChars: files?.pdf?.minTextChars ?? DEFAULT_INPUT_PDF_MIN_TEXT_CHARS,
},
};
}
function appendFileBlocks(body: string | undefined, blocks: string[]): string {
if (!blocks || blocks.length === 0) {
return body ?? "";
}
const base = typeof body === "string" ? body.trim() : "";
const suffix = blocks.join("\n\n").trim();
if (!base) {
return suffix;
}
return `${base}\n\n${suffix}`.trim();
}
function resolveUtf16Charset(buffer?: Buffer): "utf-16le" | "utf-16be" | undefined {
if (!buffer || buffer.length < 2) return undefined;
const b0 = buffer[0];
const b1 = buffer[1];
if (b0 === 0xff && b1 === 0xfe) {
return "utf-16le";
}
if (b0 === 0xfe && b1 === 0xff) {
return "utf-16be";
}
const sampleLen = Math.min(buffer.length, 2048);
let zeroCount = 0;
for (let i = 0; i < sampleLen; i += 1) {
if (buffer[i] === 0) zeroCount += 1;
}
if (zeroCount / sampleLen > 0.2) {
return "utf-16le";
}
return undefined;
}
function looksLikeUtf8Text(buffer?: Buffer): boolean {
if (!buffer || buffer.length === 0) return false;
const sampleLen = Math.min(buffer.length, 4096);
let printable = 0;
let other = 0;
for (let i = 0; i < sampleLen; i += 1) {
const byte = buffer[i];
if (byte === 0) {
other += 1;
continue;
}
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) {
printable += 1;
} else {
other += 1;
}
}
const total = printable + other;
if (total === 0) return false;
return printable / total > 0.85;
}
function decodeTextSample(buffer?: Buffer): string {
if (!buffer || buffer.length === 0) return "";
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
const utf16Charset = resolveUtf16Charset(sample);
if (utf16Charset === "utf-16be") {
const swapped = Buffer.alloc(sample.length);
for (let i = 0; i + 1 < sample.length; i += 2) {
swapped[i] = sample[i + 1];
swapped[i + 1] = sample[i];
}
return new TextDecoder("utf-16le").decode(swapped);
}
if (utf16Charset === "utf-16le") {
return new TextDecoder("utf-16le").decode(sample);
}
return new TextDecoder("utf-8").decode(sample);
}
function guessDelimitedMime(text: string): string | undefined {
if (!text) return undefined;
const line = text.split(/\r?\n/)[0] ?? "";
const tabs = (line.match(/\t/g) ?? []).length;
const commas = (line.match(/,/g) ?? []).length;
if (commas > 0) {
return "text/csv";
}
if (tabs > 0) {
return "text/tab-separated-values";
}
return undefined;
}
function resolveTextMimeFromName(name?: string): string | undefined {
if (!name) return undefined;
const ext = path.extname(name).toLowerCase();
return TEXT_EXT_MIME.get(ext);
}
async function extractFileBlocks(params: {
attachments: ReturnType<typeof normalizeMediaAttachments>;
cache: ReturnType<typeof createMediaAttachmentCache>;
limits: ReturnType<typeof resolveFileLimits>;
}): Promise<string[]> {
const { attachments, cache, limits } = params;
if (!attachments || attachments.length === 0) {
return [];
}
const blocks: string[] = [];
for (const attachment of attachments) {
if (!attachment) {
continue;
}
const forcedTextMime = resolveTextMimeFromName(attachment.path ?? attachment.url ?? "");
const kind = forcedTextMime ? "document" : resolveAttachmentKind(attachment);
if (!forcedTextMime && (kind === "image" || kind === "video")) {
continue;
}
if (!limits.allowUrl && attachment.url && !attachment.path) {
if (shouldLogVerbose()) {
logVerbose(`media: file attachment skipped (url disabled) index=${attachment.index}`);
}
continue;
}
let bufferResult: Awaited<ReturnType<typeof cache.getBuffer>>;
try {
bufferResult = await cache.getBuffer({
attachmentIndex: attachment.index,
maxBytes: limits.maxBytes,
timeoutMs: limits.timeoutMs,
});
} catch (err) {
if (shouldLogVerbose()) {
logVerbose(`media: file attachment skipped (buffer): ${String(err)}`);
}
continue;
}
const nameHint = bufferResult?.fileName ?? attachment.path ?? attachment.url;
const forcedTextMimeResolved = forcedTextMime ?? resolveTextMimeFromName(nameHint ?? "");
const utf16Charset = resolveUtf16Charset(bufferResult?.buffer);
const textSample = decodeTextSample(bufferResult?.buffer);
const textLike = Boolean(utf16Charset) || looksLikeUtf8Text(bufferResult?.buffer);
if (!forcedTextMimeResolved && kind === "audio" && !textLike) {
continue;
}
const guessedDelimited = textLike ? guessDelimitedMime(textSample) : undefined;
const textHint =
forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined);
const rawMime = bufferResult?.mime ?? attachment.mime;
const mimeType = textHint ?? normalizeMimeType(rawMime);
// Log when MIME type is overridden from non-text to text for auditability
if (textHint && rawMime && !rawMime.startsWith("text/")) {
logVerbose(
`media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`,
);
}
if (!mimeType) {
if (shouldLogVerbose()) {
logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`);
}
continue;
}
const allowedMimes = new Set(limits.allowedMimes);
for (const extra of EXTRA_TEXT_MIMES) {
allowedMimes.add(extra);
}
if (mimeType.startsWith("text/")) {
allowedMimes.add(mimeType);
}
if (!allowedMimes.has(mimeType)) {
if (shouldLogVerbose()) {
logVerbose(
`media: file attachment skipped (unsupported mime ${mimeType}) index=${attachment.index}`,
);
}
continue;
}
let extracted: Awaited<ReturnType<typeof extractFileContentFromSource>>;
try {
const mediaType = utf16Charset ? `${mimeType}; charset=${utf16Charset}` : mimeType;
extracted = await extractFileContentFromSource({
source: {
type: "base64",
data: bufferResult.buffer.toString("base64"),
mediaType,
filename: bufferResult.fileName,
},
limits: {
...limits,
allowedMimes,
},
});
} catch (err) {
if (shouldLogVerbose()) {
logVerbose(`media: file attachment skipped (extract): ${String(err)}`);
}
continue;
}
const text = extracted?.text?.trim() ?? "";
let blockText = text;
if (!blockText) {
if (extracted?.images && extracted.images.length > 0) {
blockText = "[PDF content rendered to images; images not forwarded to model]";
} else {
blockText = "[No extractable text]";
}
}
const safeName = (bufferResult.fileName ?? `file-${attachment.index + 1}`)
.replace(/[\r\n\t]+/g, " ")
.trim();
// Escape XML special characters in attributes to prevent injection
blocks.push(
`<file name="${xmlEscapeAttr(safeName)}" mime="${xmlEscapeAttr(mimeType)}">\n${blockText}\n</file>`,
);
}
return blocks;
}
export async function applyMediaUnderstanding(params: {
ctx: MsgContext;
@ -51,6 +338,12 @@ export async function applyMediaUnderstanding(params: {
const cache = createMediaAttachmentCache(attachments);
try {
const fileBlocks = await extractFileBlocks({
attachments,
cache,
limits: resolveFileLimits(cfg),
});
const tasks = CAPABILITY_ORDER.map((capability) => async () => {
const config = cfg.tools?.media?.[capability];
return await runCapability({
@ -99,7 +392,15 @@ export async function applyMediaUnderstanding(params: {
ctx.RawBody = originalUserText;
}
ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs];
finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: true });
}
if (fileBlocks.length > 0) {
ctx.Body = appendFileBlocks(ctx.Body, fileBlocks);
}
if (outputs.length > 0 || fileBlocks.length > 0) {
finalizeInboundContext(ctx, {
forceBodyForAgent: true,
forceBodyForCommands: outputs.length > 0,
});
}
return {
@ -108,6 +409,7 @@ export async function applyMediaUnderstanding(params: {
appliedImage: outputs.some((output) => output.kind === "image.description"),
appliedAudio: outputs.some((output) => output.kind === "audio.transcription"),
appliedVideo: outputs.some((output) => output.kind === "video.description"),
appliedFile: fileBlocks.length > 0,
};
} finally {
await cache.cleanup();

View File

@ -1,7 +1,7 @@
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { detectMime, imageMimeFromFormat } from "./mime.js";
import { detectMime, extensionForMime, imageMimeFromFormat } from "./mime.js";
async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise<Buffer> {
const zip = new JSZip();
@ -53,3 +53,47 @@ describe("mime detection", () => {
expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
});
});
describe("extensionForMime", () => {
it("maps image MIME types to extensions", () => {
expect(extensionForMime("image/jpeg")).toBe(".jpg");
expect(extensionForMime("image/png")).toBe(".png");
expect(extensionForMime("image/webp")).toBe(".webp");
expect(extensionForMime("image/gif")).toBe(".gif");
expect(extensionForMime("image/heic")).toBe(".heic");
});
it("maps audio MIME types to extensions", () => {
expect(extensionForMime("audio/mpeg")).toBe(".mp3");
expect(extensionForMime("audio/ogg")).toBe(".ogg");
expect(extensionForMime("audio/x-m4a")).toBe(".m4a");
expect(extensionForMime("audio/mp4")).toBe(".m4a");
});
it("maps video MIME types to extensions", () => {
expect(extensionForMime("video/mp4")).toBe(".mp4");
expect(extensionForMime("video/quicktime")).toBe(".mov");
});
it("maps document MIME types to extensions", () => {
expect(extensionForMime("application/pdf")).toBe(".pdf");
expect(extensionForMime("text/plain")).toBe(".txt");
expect(extensionForMime("text/markdown")).toBe(".md");
});
it("handles case insensitivity", () => {
expect(extensionForMime("IMAGE/JPEG")).toBe(".jpg");
expect(extensionForMime("Audio/X-M4A")).toBe(".m4a");
expect(extensionForMime("Video/QuickTime")).toBe(".mov");
});
it("returns undefined for unknown MIME types", () => {
expect(extensionForMime("video/unknown")).toBeUndefined();
expect(extensionForMime("application/x-custom")).toBeUndefined();
});
it("returns undefined for null or undefined input", () => {
expect(extensionForMime(null)).toBeUndefined();
expect(extensionForMime(undefined)).toBeUndefined();
});
});

View File

@ -13,7 +13,10 @@ const EXT_BY_MIME: Record<string, string> = {
"image/gif": ".gif",
"audio/ogg": ".ogg",
"audio/mpeg": ".mp3",
"audio/x-m4a": ".m4a",
"audio/mp4": ".m4a",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"application/pdf": ".pdf",
"application/json": ".json",
"application/zip": ".zip",

View File

@ -227,3 +227,29 @@ describe("resolveAgentRoute", () => {
expect(route.sessionKey).toBe("agent:home:main");
});
});
test("dmScope=per-account-channel-peer isolates DM sessions per account, channel and sender", () => {
const cfg: MoltbotConfig = {
session: { dmScope: "per-account-channel-peer" },
};
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: "tasks",
peer: { kind: "dm", id: "7550356539" },
});
expect(route.sessionKey).toBe("agent:main:telegram:tasks:dm:7550356539");
});
test("dmScope=per-account-channel-peer uses default accountId when not provided", () => {
const cfg: MoltbotConfig = {
session: { dmScope: "per-account-channel-peer" },
};
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: null,
peer: { kind: "dm", id: "7550356539" },
});
expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539");
});

View File

@ -69,9 +69,10 @@ function matchesAccountId(match: string | undefined, actual: string): boolean {
export function buildAgentSessionKey(params: {
agentId: string;
channel: string;
accountId?: string | null;
peer?: RoutePeer | null;
/** DM session scope. */
dmScope?: "main" | "per-peer" | "per-channel-peer";
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
identityLinks?: Record<string, string[]>;
}): string {
const channel = normalizeToken(params.channel) || "unknown";
@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: {
agentId: params.agentId,
mainKey: DEFAULT_MAIN_KEY,
channel,
accountId: params.accountId,
peerKind: peer?.kind ?? "dm",
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
dmScope: params.dmScope,
@ -160,6 +162,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
const sessionKey = buildAgentSessionKey({
agentId: resolvedAgentId,
channel,
accountId,
peer,
dmScope,
identityLinks,

View File

@ -111,11 +111,12 @@ export function buildAgentPeerSessionKey(params: {
agentId: string;
mainKey?: string | undefined;
channel: string;
accountId?: string | null;
peerKind?: "dm" | "group" | "channel" | null;
peerId?: string | null;
identityLinks?: Record<string, string[]>;
/** DM session scope. */
dmScope?: "main" | "per-peer" | "per-channel-peer";
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
}): string {
const peerKind = params.peerKind ?? "dm";
if (peerKind === "dm") {
@ -131,6 +132,11 @@ export function buildAgentPeerSessionKey(params: {
});
if (linkedPeerId) peerId = linkedPeerId;
peerId = peerId.toLowerCase();
if (dmScope === "per-account-channel-peer" && peerId) {
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
const accountId = normalizeAccountId(params.accountId);
return `agent:${normalizeAgentId(params.agentId)}:${channel}:${accountId}:dm:${peerId}`;
}
if (dmScope === "per-channel-peer" && peerId) {
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`;

View File

@ -519,7 +519,8 @@ async function collectChannelSecurityFindings(params: {
title: `${input.label} DMs share the main session`,
detail:
"Multiple DM senders currently share the main session, which can leak context across users.",
remediation: 'Set session.dmScope="per-channel-peer" to isolate DM sessions per sender.',
remediation:
'Set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate DM sessions per sender.',
});
}
};

View File

@ -310,7 +310,14 @@ export async function resolveMedia(
fetchImpl,
filePathHint: file.file_path,
});
const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
const originalName = fetched.fileName ?? file.file_path;
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
maxBytes,
originalName,
);
// Check sticker cache for existing description
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
@ -377,7 +384,14 @@ export async function resolveMedia(
fetchImpl,
filePathHint: file.file_path,
});
const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
const originalName = fetched.fileName ?? file.file_path;
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
maxBytes,
originalName,
);
let placeholder = "<media:document>";
if (msg.photo) placeholder = "<media:image>";
else if (msg.video) placeholder = "<media:video>";

View File

@ -13,8 +13,12 @@ describe("resolveTelegramForumThreadId", () => {
});
it("returns undefined for non-forum groups without messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined })).toBeUndefined();
expect(resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 })).toBeUndefined();
expect(
resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined }),
).toBeUndefined();
expect(
resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 }),
).toBeUndefined();
});
it("returns General topic (1) for forum groups without messageThreadId", () => {

View File

@ -40,7 +40,7 @@ export async function downloadTelegramFile(
filePath: info.file_path,
});
// save with inbound subdir
const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes);
const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes, info.file_path);
// Ensure extension matches mime if possible
if (!saved.contentType && mime) saved.contentType = mime;
return saved;

View File

@ -757,11 +757,19 @@ export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as con
* Custom OpenAI-compatible TTS endpoint.
* When set, model/voice validation is relaxed to allow non-OpenAI models.
* Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1
*
* Note: Read at runtime (not module load) to support config.env loading.
*/
const OPENAI_TTS_BASE_URL = (
process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1"
).replace(/\/+$/, "");
const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1";
function getOpenAITtsBaseUrl(): string {
return (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace(
/\/+$/,
"",
);
}
function isCustomOpenAIEndpoint(): boolean {
return getOpenAITtsBaseUrl() !== "https://api.openai.com/v1";
}
export const OPENAI_TTS_VOICES = [
"alloy",
"ash",
@ -778,13 +786,13 @@ type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
function isValidOpenAIModel(model: string): boolean {
// Allow any model when using custom endpoint (e.g., Kokoro, LocalAI)
if (isCustomOpenAIEndpoint) return true;
if (isCustomOpenAIEndpoint()) return true;
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
}
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
// Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices)
if (isCustomOpenAIEndpoint) return true;
if (isCustomOpenAIEndpoint()) return true;
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
}
@ -1011,7 +1019,7 @@ async function openaiTTS(params: {
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, {
const response = await fetch(`${getOpenAITtsBaseUrl()}/audio/speech`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,

View File

@ -54,11 +54,13 @@ export async function maybeBroadcastMessage(params: {
sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId,
channel: "whatsapp",
accountId: params.route.accountId,
peer: {
kind: params.msg.chatType === "group" ? "group" : "dm",
id: params.peerId,
},
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
}),
mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId,

View File

@ -42,6 +42,7 @@ import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderExecApprovalPrompt } from "./views/exec-approval";
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation";
import {
approveDevicePairing,
loadDevices,
@ -578,6 +579,7 @@ export function renderApp(state: AppViewState) {
: nothing}
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
</div>
`;
}

View File

@ -33,6 +33,7 @@ type SettingsHost = {
basePath: string;
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
@ -98,7 +99,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
applySettings(host, { ...host.settings, gatewayUrl });
host.pendingGatewayUrl = gatewayUrl;
}
params.delete("gatewayUrl");
shouldCleanUrl = true;

View File

@ -73,6 +73,7 @@ export type AppViewState = {
execApprovalQueue: ExecApprovalRequest[];
execApprovalBusy: boolean;
execApprovalError: string | null;
pendingGatewayUrl: string | null;
configLoading: boolean;
configRaw: string;
configRawOriginal: string;
@ -165,6 +166,8 @@ export type AppViewState = {
handleNostrProfileImport: () => Promise<void>;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleGatewayUrlConfirm: () => void;
handleGatewayUrlCancel: () => void;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;

View File

@ -152,6 +152,7 @@ export class MoltbotApp extends LitElement {
@state() execApprovalQueue: ExecApprovalRequest[] = [];
@state() execApprovalBusy = false;
@state() execApprovalError: string | null = null;
@state() pendingGatewayUrl: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@ -448,6 +449,21 @@ export class MoltbotApp extends LitElement {
}
}
handleGatewayUrlConfirm() {
const nextGatewayUrl = this.pendingGatewayUrl;
if (!nextGatewayUrl) return;
this.pendingGatewayUrl = null;
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
{ ...this.settings, gatewayUrl: nextGatewayUrl },
);
this.connect();
}
handleGatewayUrlCancel() {
this.pendingGatewayUrl = null;
}
// Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) {

View File

@ -260,6 +260,11 @@ function renderTextInput(params: {
}
onPatch(path, raw);
}}
@change=${(e: Event) => {
if (inputType === "number") return;
const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim());
}}
/>
${schema.default !== undefined ? html`
<button

View File

@ -0,0 +1,39 @@
import { html, nothing } from "lit";
import type { AppViewState } from "../app-view-state";
export function renderGatewayUrlConfirmation(state: AppViewState) {
const { pendingGatewayUrl } = state;
if (!pendingGatewayUrl) return nothing;
return html`
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">Change Gateway URL</div>
<div class="exec-approval-sub">This will reconnect to a different gateway server</div>
</div>
</div>
<div class="exec-approval-command mono">${pendingGatewayUrl}</div>
<div class="callout danger" style="margin-top: 12px;">
Only confirm if you trust this URL. Malicious URLs can compromise your system.
</div>
<div class="exec-approval-actions">
<button
class="btn primary"
@click=${() => state.handleGatewayUrlConfirm()}
>
Confirm
</button>
<button
class="btn"
@click=${() => state.handleGatewayUrlCancel()}
>
Cancel
</button>
</div>
</div>
</div>
`;
}