mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
feat(sdk,webclient,gateway): private message (DM) chat between players
The engine already supported MESSAGE_PRIVATE end-to-end; this adds the send path and tooling: - sendPrivateMessage on browser Client + LiteClient (userhash + WordPack payload, local type-6 echo), new 'privateMessage' bot action - sdk.sendPrivateMessage(name, msg) + sdk.dm(name, text) (chunked like say) - gateway: observe mode whitelists privateMessage alongside say; new POST /dm/:username endpoint - a separate path from /say on purpose, so old gateways' non-JSON banner makes the CLI fall back to the WS path (cleanly rejected) instead of leaking a DM as public chat - chat CLI: bun sdk/chat.ts <bot> --to <player> "msg" - engine: FriendServer PM handler logs private_chat best-effort instead of letting a missing account row (local easy-startup) kill the relay PMs deliver to any online player - no friends list needed. One social packet (say or PM) per tick; dm() paces its chunks. Delivery requires FRIEND_SERVER=true (prod fly.toml already sets it; local quickstart defaults off and drops PMs silently). Verified E2E on the local stack: both directions, 3-chunk long message, observe-mode WS path, non-chat actions still rejected in observe mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e960b3a8d2
commit
042ad988c0
13 files changed
+321
-30
No files matched your search
@@ -128,7 +128,8 @@ chat (and nothing else) and never pre-empts (or gets pre-empted by) a
|
||||
controller:
|
||||
|
||||
```bash
|
||||
bun sdk/chat.ts {username} "meet me at the bank" # send
|
||||
bun sdk/chat.ts {username} "meet me at the bank" # send public chat
|
||||
bun sdk/chat.ts {username} --to {player} "on my way" # send a private message
|
||||
bun sdk/chat.ts {username} # recent chat
|
||||
bun sdk/chat.ts {username} --watch # tail live
|
||||
```
|
||||
@@ -136,6 +137,11 @@ bun sdk/chat.ts {username} --watch # tail live
|
||||
Public chat is capped at 400 chars per message; `sdk.say()` auto-splits longer
|
||||
text into wire-safe chunks.
|
||||
|
||||
Private messages (`sdk.dm(name, text)` / `sdk.sendPrivateMessage(name, msg)`)
|
||||
reach the target anywhere in the world - no friends-list setup needed - and
|
||||
show up in `getChat()` as type 3 (received) / 6 (sent). The server accepts one
|
||||
social packet (say or PM) per tick; `dm()` paces its chunks automatically.
|
||||
|
||||
## Script Duration Guidelines
|
||||
|
||||
**Start short, extend as you gain confidence:**
|
||||
@@ -215,6 +221,7 @@ For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-genera
|
||||
| `sendUseItem(slot)` | Use inventory item (bury, etc.) |
|
||||
| `sendUseItemOnItem(src, dst)` | Combine two items |
|
||||
| `say(text)` | Send chat, auto-chunked past the length cap |
|
||||
| `dm(name, text)` | Send a private message, auto-chunked |
|
||||
| `getChat(opts?)` / `getNewChat(opts?)` | Read chat history (500-deep) / only unseen messages |
|
||||
| `waitForChat(opts?)` | Wait for a message (`{from, matching, timeout}`) |
|
||||
| `waitForCondition(pred)` | Wait for state predicate |
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
| `async launchBrowser(): Promise<void>` | Launch native browser to client URL. Uses the `open` package for cross-platform support (macOS, Windows, Linux, WSL). Falls back to printing the URL if no browser can be opened. |
|
||||
| `countInventoryItems(pattern: string \| RegExp): number` | Count total item quantity matching a name pattern. This sums stack sizes across every matching slot. Use `getInventory().filter(...)` when the number of occupied slots is needed. |
|
||||
| `async clickInterfaceOption(selector: InterfaceOptionSelector): Promise<ActionResult>` | Click exactly one interface option, selected by its state object or by visible text (substring for strings, match for regexes). This dispatches the option's `componentId` and never interprets `InterfaceOption.index` as an array position. |
|
||||
| `async dm(targetName: string, text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]>` | Send a private message of any length, auto-split into chunks like {@link say}. Returns one ActionResult per chunk. |
|
||||
| `blockDoorTemporarily(level: number, x: number, z: number, ttlMs: number = 30_000): boolean` | Temporarily exclude a known door from this SDK instance's path queries. The shared collision map is never mutated beyond the synchronous query. |
|
||||
|
||||
### Condition Waiting
|
||||
@@ -269,6 +270,7 @@
|
||||
| `async sendSetTab(tabIndex: number): Promise<ActionResult>` | Switch to a UI tab by index. |
|
||||
| `async sendSay(message: string): Promise<ActionResult>` | Send a single chat message. The server caps public chat at {@link maxMessageLength} chars (400 on rs-sdk servers) and runs a word filter; `result.data` reports `{ sent, truncated, filtered, finalText }` so you know if your message was clipped or censored. For longer text that shouldn't be silently truncated, use {@link say}. |
|
||||
| `async say(text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]>` | Send a message of any length, auto-split into chunks on word boundaries and sent in order (so a multi-sentence plan isn't lost to the chat-length cap). Waits a tick between chunks so they don't collide. Returns one ActionResult per chunk. |
|
||||
| `async sendPrivateMessage(targetName: string, message: string): Promise<ActionResult>` | Send a single private message to another player by name. Delivered if the target is online anywhere in the world - no friends-list setup needed. It arrives in their chat as type 3 ("From <you>"), and echoes locally as type 6 ("To <name>"). Same length cap and word filter as {@link sendSay}; the server accepts at most one social packet (say or PM) per tick, so don't fire this back-to-back with public chat in the same tick. For longer text, use {@link dm}. |
|
||||
| `async sendWait(ticks: number = 1): Promise<ActionResult>` | Wait for specified number of game ticks. |
|
||||
| `async sendBankDeposit(slot: number, amount: number = 1): Promise<ActionResult>` | Deposit item to bank by slot. |
|
||||
| `async sendBankWithdraw(slot: number, amount: number = 1): Promise<ActionResult>` | Withdraw item from bank by slot. |
|
||||
|
||||
+38
-10
@@ -30,9 +30,11 @@ SDK Chat CLI - send and read chat without stealing bot control
|
||||
Usage:
|
||||
bun sdk/chat.ts <botname> # Show recent chat
|
||||
bun sdk/chat.ts <botname> <message...> # Send a message
|
||||
bun sdk/chat.ts <botname> --to <player> <message...> # Send a private message
|
||||
bun sdk/chat.ts <botname> --watch # Tail chat live (Ctrl+C to stop)
|
||||
|
||||
Options:
|
||||
--to <player> Send as a private message to this player instead of public chat
|
||||
--limit <n> Messages of history to show (default: 20)
|
||||
--system Include system/game messages, not just player chat
|
||||
--server <host> Server hostname (default: from bot.env or rs-sdk-demo.fly.dev)
|
||||
@@ -41,9 +43,13 @@ Options:
|
||||
|
||||
Examples:
|
||||
bun sdk/chat.ts mybot "meet me at the bank"
|
||||
bun sdk/chat.ts mybot --to otherbot "got the ruby, banking now"
|
||||
bun sdk/chat.ts mybot --watch
|
||||
bun sdk/chat.ts mybot --limit 50 --system
|
||||
|
||||
Private messages reach the target anywhere in the world (no friends-list setup
|
||||
needed) and show up in chat reads as "[PM from ...]" / "[PM to ...]".
|
||||
|
||||
Sending requires a game client (browser or lite) to be logged in as the bot;
|
||||
this tool only relays chat through it. Reading works off the same state feed
|
||||
observers get.
|
||||
@@ -126,9 +132,17 @@ interface CliContext {
|
||||
|
||||
// ============ HTTP paths (primary) ============
|
||||
|
||||
/** Returns exit code, or null if the gateway lacks the HTTP endpoints. */
|
||||
async function sendViaHttp(ctx: CliContext, text: string): Promise<number | null> {
|
||||
const url = `${gatewayHttpBase(ctx.gatewayUrl)}/say/${encodeURIComponent(ctx.username)}`;
|
||||
/**
|
||||
* Returns exit code, or null if the gateway lacks the HTTP endpoints.
|
||||
* With `to` set, posts to /dm (a distinct path, so a pre-DM gateway answers
|
||||
* with its non-JSON banner and we fall back to WebSocket - a DM must never
|
||||
* silently degrade into public chat).
|
||||
*/
|
||||
async function sendViaHttp(ctx: CliContext, text: string, to?: string): Promise<number | null> {
|
||||
const base = gatewayHttpBase(ctx.gatewayUrl);
|
||||
const url = to
|
||||
? `${base}/dm/${encodeURIComponent(ctx.username)}`
|
||||
: `${base}/say/${encodeURIComponent(ctx.username)}`;
|
||||
let res: Response;
|
||||
try {
|
||||
// The gateway bounds each chunk at 10s and aborts on first failure;
|
||||
@@ -136,7 +150,7 @@ async function sendViaHttp(ctx: CliContext, text: string): Promise<number | null
|
||||
res = await boundedFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, password: ctx.password, timeoutMs: 10_000 })
|
||||
body: JSON.stringify({ text, to, password: ctx.password, timeoutMs: 10_000 })
|
||||
}, 60_000);
|
||||
} catch (err: any) {
|
||||
console.error(`Error: Failed to reach gateway at ${url}`);
|
||||
@@ -160,7 +174,7 @@ async function sendViaHttp(ctx: CliContext, text: string): Promise<number | null
|
||||
let ok = true;
|
||||
for (const result of body.results ?? []) {
|
||||
if (result.success) {
|
||||
console.log(`Sent: ${result.data?.finalText ?? result.chunk}`);
|
||||
console.log(`Sent${to ? ` [PM to ${to}]` : ''}: ${result.data?.finalText ?? result.chunk}`);
|
||||
if (result.data?.truncated) console.log(' (truncated by server cap)');
|
||||
if (result.data?.filtered) console.log(' (altered by word filter)');
|
||||
} else {
|
||||
@@ -208,7 +222,7 @@ async function readViaHttp(ctx: CliContext): Promise<number | null> {
|
||||
|
||||
// ============ Observer-WebSocket path (fallback + --watch) ============
|
||||
|
||||
async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: boolean): Promise<number> {
|
||||
async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: boolean, dmTarget?: string): Promise<number> {
|
||||
const sdk = new BotSDK({
|
||||
botUsername: ctx.username,
|
||||
password: ctx.password,
|
||||
@@ -231,12 +245,12 @@ async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: b
|
||||
}
|
||||
|
||||
if (messageToSend) {
|
||||
const results = await sdk.say(messageToSend);
|
||||
const results = dmTarget ? await sdk.dm(dmTarget, messageToSend) : await sdk.say(messageToSend);
|
||||
let ok = true;
|
||||
for (const result of results) {
|
||||
if (result.success) {
|
||||
const data = result.data as { finalText?: string; truncated?: boolean; filtered?: boolean } | undefined;
|
||||
console.log(`Sent: ${data?.finalText ?? messageToSend}`);
|
||||
console.log(`Sent${dmTarget ? ` [PM to ${dmTarget}]` : ''}: ${data?.finalText ?? messageToSend}`);
|
||||
if (data?.truncated) console.log(' (truncated by server cap)');
|
||||
if (data?.filtered) console.log(' (altered by word filter)');
|
||||
} else {
|
||||
@@ -246,6 +260,9 @@ async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: b
|
||||
console.error(` No game client is logged in as '${ctx.username}' - chat is relayed through it.`);
|
||||
console.error(` Log the bot in (browser or lite client), then retry.`);
|
||||
}
|
||||
if (dmTarget && /observe mode can only send 'say'/i.test(result.message)) {
|
||||
console.error(` This gateway predates private-message support - redeploy the gateway to enable DMs.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
sdk.disconnect();
|
||||
@@ -304,6 +321,7 @@ async function main() {
|
||||
let limit = 20;
|
||||
let watch = false;
|
||||
let system = false;
|
||||
let dmTarget = '';
|
||||
const positional: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -315,6 +333,8 @@ async function main() {
|
||||
watch = true;
|
||||
} else if (arg === '--system') {
|
||||
system = true;
|
||||
} else if (arg === '--to') {
|
||||
dmTarget = args[++i] || '';
|
||||
} else if (arg === '--server') {
|
||||
server = args[++i] || '';
|
||||
} else if (arg === '--timeout') {
|
||||
@@ -332,6 +352,14 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
const messageToSend = positional.slice(1).join(' ');
|
||||
if (dmTarget && !messageToSend) {
|
||||
console.error('Error: --to requires a message to send');
|
||||
process.exit(1);
|
||||
}
|
||||
if (dmTarget.length > 12) {
|
||||
console.error(`Error: '--to ${dmTarget}' - player names are at most 12 characters`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let username = botName;
|
||||
let password = process.env.PASSWORD || '';
|
||||
@@ -367,10 +395,10 @@ async function main() {
|
||||
}, HARD_DEADLINE_MS);
|
||||
if (typeof (deadline as any).unref === 'function') (deadline as any).unref();
|
||||
|
||||
const httpExit = messageToSend ? await sendViaHttp(ctx, messageToSend) : await readViaHttp(ctx);
|
||||
const httpExit = messageToSend ? await sendViaHttp(ctx, messageToSend, dmTarget || undefined) : await readViaHttp(ctx);
|
||||
if (httpExit !== null) process.exit(httpExit);
|
||||
// Gateway predates the HTTP chat endpoints - use the observer socket.
|
||||
process.exit(await runOverWebSocket(ctx, messageToSend, false));
|
||||
process.exit(await runOverWebSocket(ctx, messageToSend, false, dmTarget || undefined));
|
||||
}
|
||||
|
||||
await runOverWebSocket(ctx, '', true);
|
||||
|
||||
@@ -1511,6 +1511,48 @@ export class BotSDK {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single private message to another player by name. Delivered if the
|
||||
* target is online anywhere in the world - no friends-list setup needed. It
|
||||
* arrives in their chat as type 3 ("From <you>"), and echoes locally as
|
||||
* type 6 ("To <name>"). Same length cap and word filter as {@link sendSay};
|
||||
* the server accepts at most one social packet (say or PM) per tick, so
|
||||
* don't fire this back-to-back with public chat in the same tick.
|
||||
* For longer text, use {@link dm}.
|
||||
*/
|
||||
async sendPrivateMessage(targetName: string, message: string): Promise<ActionResult> {
|
||||
return this.sendAction({ type: 'privateMessage', targetName, message, reason: 'SDK' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a private message of any length, auto-split into chunks like
|
||||
* {@link say}. Returns one ActionResult per chunk.
|
||||
*
|
||||
* @param targetName The recipient's username (max 12 chars).
|
||||
* @param text The full message to send.
|
||||
* @param opts.maxLen Max chars per chunk, capped at {@link maxMessageLength}.
|
||||
* @param opts.delayTicks Ticks to wait between chunks (default 1).
|
||||
*/
|
||||
async dm(targetName: string, text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]> {
|
||||
const maxLen = Math.min(opts.maxLen ?? this.serverMaxMessageLength, this.serverMaxMessageLength);
|
||||
const delayTicks = opts.delayTicks ?? 1;
|
||||
const chunks = chunkMessage(text, maxLen);
|
||||
const results: ActionResult[] = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
results.push(await this.sendPrivateMessage(targetName, chunks[i]!));
|
||||
if (i < chunks.length - 1 && delayTicks > 0) {
|
||||
if (this.config.connectionMode === 'observe') {
|
||||
// Observe mode may only dispatch chat actions - a 'wait' would
|
||||
// occupy the executor of whatever controller owns the bot.
|
||||
await new Promise(resolve => setTimeout(resolve, delayTicks * 600));
|
||||
} else {
|
||||
await this.sendWait(delayTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Wait for specified number of game ticks. */
|
||||
async sendWait(ticks: number = 1): Promise<ActionResult> {
|
||||
return this.sendAction({ type: 'wait', ticks, reason: 'SDK' });
|
||||
|
||||
@@ -600,6 +600,7 @@ export type BotAction =
|
||||
| { type: 'useItemOnLoc'; itemSlot: number; x: number; z: number; locId: number; reason: string }
|
||||
| { type: 'useItemOnNpc'; itemSlot: number; npcIndex: number; reason: string }
|
||||
| { type: 'say'; message: string; reason: string }
|
||||
| { type: 'privateMessage'; targetName: string; message: string; reason: string }
|
||||
| { type: 'spellOnNpc'; npcIndex: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnPlayer'; playerIndex: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnItem'; slot: number; spellComponent: number; reason: string }
|
||||
|
||||
@@ -267,20 +267,27 @@ export class FriendServer {
|
||||
const targetUsername37 = BigInt(message.targetUsername37);
|
||||
const { staffLvl, pmId, chat } = message;
|
||||
|
||||
const from = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(username37)).executeTakeFirstOrThrow();
|
||||
const to = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(targetUsername37)).executeTakeFirstOrThrow();
|
||||
// Chat logging is best-effort: without account rows (local
|
||||
// easy-startup logins never create them) the lookup throws,
|
||||
// and that must not eat the message before the relay below.
|
||||
try {
|
||||
const from = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(username37)).executeTakeFirstOrThrow();
|
||||
const to = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(targetUsername37)).executeTakeFirstOrThrow();
|
||||
|
||||
await db
|
||||
.insertInto('private_chat')
|
||||
.values({
|
||||
account_id: from.id,
|
||||
profile: message.profile,
|
||||
to_account_id: to.id,
|
||||
timestamp: toDbDate(Date.now()),
|
||||
coord: message.coord,
|
||||
message: chat
|
||||
})
|
||||
.execute();
|
||||
await db
|
||||
.insertInto('private_chat')
|
||||
.values({
|
||||
account_id: from.id,
|
||||
profile: message.profile,
|
||||
to_account_id: to.id,
|
||||
timestamp: toDbDate(Date.now()),
|
||||
coord: message.coord,
|
||||
message: chat
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
console.error(`[Friends]: failed to log private chat: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
await this.sendPrivateMessage(username37, staffLvl, pmId, targetUsername37, chat);
|
||||
} else if (type === FriendsClientOpcodes.PUBLIC_CHAT_LOG) {
|
||||
|
||||
@@ -515,13 +515,13 @@ const SyncModule = {
|
||||
const sdkSession = this.getSessionForSocket(ws);
|
||||
if (!sdkSession) return;
|
||||
|
||||
// Gate actions based on mode: observers may only send 'say', so chat
|
||||
// tools can talk without being able to drive the bot.
|
||||
if (sdkSession.mode === 'observe' && message.action?.type !== 'say') {
|
||||
// Gate actions based on mode: observers may only send chat ('say' /
|
||||
// 'privateMessage'), so chat tools can talk without driving the bot.
|
||||
if (sdkSession.mode === 'observe' && message.action?.type !== 'say' && message.action?.type !== 'privateMessage') {
|
||||
this.sendToSDK(sdkSession, {
|
||||
type: 'sdk_error',
|
||||
actionId: message.actionId,
|
||||
error: `Observe mode can only send 'say' actions (got '${message.action?.type}')`
|
||||
error: `Observe mode can only send 'say' or 'privateMessage' actions (got '${message.action?.type}')`
|
||||
});
|
||||
console.log(`[Gateway] [${sdkSession.targetUsername}] Rejected action from observe-mode SDK: ${message.action?.type}`);
|
||||
return;
|
||||
@@ -664,7 +664,7 @@ async function authenticateHttpRequest(req: Request, url: URL, username: string,
|
||||
* actionResult. Never rejects: a client that doesn't answer inside the budget
|
||||
* yields a failed result, so the HTTP handler always responds.
|
||||
*/
|
||||
function dispatchHttpSay(botSession: BotSession, text: string, timeoutMs: number): Promise<{ success: boolean; message: string; data?: any; reason?: string }> {
|
||||
function dispatchHttpSay(botSession: BotSession, text: string, timeoutMs: number, to?: string): Promise<{ success: boolean; message: string; data?: any; reason?: string }> {
|
||||
const actionId = `http-${++httpActionSeq}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return new Promise(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -676,7 +676,9 @@ function dispatchHttpSay(botSession: BotSession, text: string, timeoutMs: number
|
||||
botSession.currentActionId = actionId;
|
||||
SyncModule.sendToBot(botSession, {
|
||||
type: 'action',
|
||||
action: { type: 'say', message: text, reason: 'HTTP chat' },
|
||||
action: to
|
||||
? { type: 'privateMessage', targetName: to, message: text, reason: 'HTTP chat' }
|
||||
: { type: 'say', message: text, reason: 'HTTP chat' },
|
||||
actionId,
|
||||
actionTimeoutMs: timeoutMs
|
||||
});
|
||||
@@ -883,6 +885,64 @@ const server = Bun.serve({
|
||||
// Relays through the bot's game client like observer 'say' does, but as
|
||||
// a single request/response with a hard per-chunk deadline - a caller
|
||||
// can trust that this either answers or fails within its budget.
|
||||
// Send a private message: POST /dm/:username body: {"to": "...", "text": "...", "password"?: "...", "timeoutMs"?: n}
|
||||
//
|
||||
// A distinct path (not a `to` field on /say) on purpose: gateways that
|
||||
// predate DMs answer unknown paths with the text/plain banner, which
|
||||
// callers detect and handle - a DM must never silently degrade into
|
||||
// public chat.
|
||||
const dmMatch = url.pathname.match(/^\/dm\/(.+)$/);
|
||||
if (dmMatch && dmMatch[1] && req.method === 'POST') {
|
||||
const username = decodeURIComponent(dmMatch[1]);
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: 'Body must be JSON: {"to": "...", "text": "..."}' }, 400);
|
||||
}
|
||||
const text = typeof body?.text === 'string' ? body.text.trim() : '';
|
||||
const to = typeof body?.to === 'string' ? body.to.trim() : '';
|
||||
if (!text) {
|
||||
return jsonResponse({ error: 'Missing "text"' }, 400);
|
||||
}
|
||||
if (!to || to.length > 12) {
|
||||
return jsonResponse({ error: 'Missing or invalid "to" (target player name, max 12 chars)' }, 400);
|
||||
}
|
||||
const auth = await authenticateHttpRequest(req, url, username, body?.password);
|
||||
if (!auth.success) {
|
||||
return jsonResponse({ error: `Authentication failed: ${auth.error}` }, 401);
|
||||
}
|
||||
|
||||
const botSession = botSessions.get(username);
|
||||
if (!botSession || !botSession.ws) {
|
||||
return jsonResponse({
|
||||
error: `Bot not connected - no game client is logged in as '${username}', so there is nothing to relay chat through`
|
||||
}, 409);
|
||||
}
|
||||
|
||||
const timeoutRaw = Number(body?.timeoutMs);
|
||||
const perChunkTimeout = Math.min(Math.max(Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 10000, 2000), 30000);
|
||||
const chunks = chunkMessage(text, botSession.maxMessageLength ?? 80);
|
||||
|
||||
const results: any[] = [];
|
||||
let ok = true;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const result = await dispatchHttpSay(botSession, chunks[i]!, perChunkTimeout, to);
|
||||
results.push({ chunk: chunks[i], ...result });
|
||||
if (!result.success) {
|
||||
ok = false;
|
||||
for (let j = i + 1; j < chunks.length; j++) {
|
||||
results.push({ chunk: chunks[j], success: false, message: 'Skipped: previous chunk failed' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (i < chunks.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 600)); // ~1 tick so chunks don't collide
|
||||
}
|
||||
}
|
||||
return jsonResponse({ ok, to, results }, ok ? 200 : 502);
|
||||
}
|
||||
|
||||
const sayMatch = url.pathname.match(/^\/say\/(.+)$/);
|
||||
if (sayMatch && sayMatch[1] && req.method === 'POST') {
|
||||
const username = decodeURIComponent(sayMatch[1]);
|
||||
@@ -972,6 +1032,7 @@ Endpoints:
|
||||
- GET /status/:username Per-bot status (controllers, observers)
|
||||
- GET /chat/:username Recent chat (?limit=20&system=1)
|
||||
- POST /say/:username Send chat ({"text": "..."}); relayed through the bot's game client
|
||||
- POST /dm/:username Send a private message ({"to": "...", "text": "..."})
|
||||
|
||||
WebSocket:
|
||||
- ws://localhost:${GATEWAY_PORT} Bot/SDK connections
|
||||
|
||||
@@ -335,6 +335,26 @@ export class ActionExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
case 'privateMessage': {
|
||||
const pm = this.client.sendPrivateMessage(action.targetName, action.message);
|
||||
if (!pm.ok) {
|
||||
return { success: false, message: `Failed to send PM to ${action.targetName}` };
|
||||
}
|
||||
let message = `PM to ${action.targetName}: ${pm.finalText}`;
|
||||
if (pm.truncated) message += ` (truncated to ${this.client.getMaxMessageLength()} chars)`;
|
||||
if (pm.filtered) message += ' (word-filtered)';
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data: {
|
||||
sent: true,
|
||||
truncated: pm.truncated,
|
||||
filtered: pm.filtered,
|
||||
finalText: pm.finalText
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'scanNearbyLocs':
|
||||
if (!this.scanProvider) {
|
||||
return { success: false, message: 'No scan provider available' };
|
||||
@@ -499,6 +519,7 @@ export function formatAction(action: BotAction): string {
|
||||
case 'acceptCharacterDesign': return 'Accept character design';
|
||||
case 'randomizeCharacterDesign': return 'Randomize character design';
|
||||
case 'say': return `Say: ${action.message}`;
|
||||
case 'privateMessage': return `PM ${action.targetName}: ${action.message}`;
|
||||
case 'togglePrayer': return `Toggle prayer ${action.prayerIndex}`;
|
||||
default: return action.type;
|
||||
}
|
||||
|
||||
@@ -506,6 +506,7 @@ export type BotAction =
|
||||
| { type: 'spellOnGroundItem'; x: number; z: number; itemId: number; spellComponent: number; reason: string }
|
||||
| { type: 'setTab'; tabIndex: number; reason: string }
|
||||
| { type: 'say'; message: string; reason: string }
|
||||
| { type: 'privateMessage'; targetName: string; message: string; reason: string }
|
||||
| { type: 'bankDeposit'; slot: number; amount: number; reason: string }
|
||||
| { type: 'bankWithdraw'; slot: number; amount: number; reason: string }
|
||||
| { type: 'submitCountDialog'; value: number; reason: string }
|
||||
|
||||
@@ -2015,6 +2015,42 @@ export class Client extends GameShell {
|
||||
return { ok: true, truncated, filtered, finalText: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a private message to another player (MESSAGE_PRIVATE). Mirrors the
|
||||
* socialInput path the friends-tab UI uses, without needing the target on
|
||||
* the friends list - the server relays PMs to any online player.
|
||||
*/
|
||||
sendPrivateMessage(targetName: string, message: string): SayOutcome {
|
||||
if (!this.ingame || !this.out) {
|
||||
return { ok: false, truncated: false, filtered: false, finalText: '' };
|
||||
}
|
||||
|
||||
const userhash: bigint = JString.toUserhash(targetName);
|
||||
if (userhash === 0n) {
|
||||
return { ok: false, truncated: false, filtered: false, finalText: '' };
|
||||
}
|
||||
|
||||
const truncated: boolean = message.length > Client.maxMessageLength;
|
||||
let text = message.substring(0, Client.maxMessageLength);
|
||||
|
||||
this.out.p1Enc(ClientProt.MESSAGE_PRIVATE);
|
||||
this.out.p1(0);
|
||||
const start: number = this.out.pos;
|
||||
|
||||
this.out.p8(userhash);
|
||||
WordPack.pack(this.out, text);
|
||||
this.out.psize1(this.out.pos - start);
|
||||
|
||||
const cased = JString.toSentenceCase(text);
|
||||
text = WordFilter.filter(cased);
|
||||
const filtered: boolean = text !== cased;
|
||||
|
||||
// Local echo as a sent-PM (type 6) - the server never echoes your own PM back.
|
||||
this.addChat(6, text, JString.toScreenName(JString.toRawUsername(userhash)));
|
||||
|
||||
return { ok: true, truncated, filtered, finalText: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Use one inventory item on another inventory item (OPHELDU)
|
||||
*/
|
||||
|
||||
@@ -860,6 +860,9 @@ export class LiteClient {
|
||||
say(message: string): A.SayOutcome {
|
||||
return A.say(this, message);
|
||||
}
|
||||
sendPrivateMessage(targetName: string, message: string): A.SayOutcome {
|
||||
return A.sendPrivateMessage(this, targetName, message);
|
||||
}
|
||||
setCombatStyle(style: number): boolean {
|
||||
return A.setCombatStyle(this, style);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { beforeAll, describe, expect, test } from 'bun:test';
|
||||
import './dom-shim.js';
|
||||
|
||||
import CollisionMap from '#/dash3d/CollisionMap.js';
|
||||
import JString from '#/datastruct/JString.js';
|
||||
import { ClientProt } from '#/io/ClientProt.js';
|
||||
import Packet from '#/io/Packet.js';
|
||||
import { ServerProt } from '#/io/ServerProt.js';
|
||||
@@ -99,6 +100,13 @@ class Capture {
|
||||
this.pos += 2;
|
||||
return value;
|
||||
}
|
||||
g8(): bigint {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
value = (value << 8n) | BigInt(this.buf.data[this.pos++]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
rest(until: number): number[] {
|
||||
return Array.from(this.buf.data.subarray(this.pos, until));
|
||||
}
|
||||
@@ -225,6 +233,46 @@ describe('outgoing action encoding', () => {
|
||||
expect(c.localPlayer!.chatTimer).toBe(150);
|
||||
});
|
||||
|
||||
test('MESSAGE_PRIVATE carries the target userhash inside the size byte, then packed text', () => {
|
||||
if (!available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c = makeClient('bota');
|
||||
|
||||
const start = c.out.pos;
|
||||
const outcome = c.sendPrivateMessage('botb', 'hello world');
|
||||
expect(outcome.ok).toBe(true);
|
||||
|
||||
// The oracle: pack the same text the way the browser client does.
|
||||
const scratch = Packet.alloc(0);
|
||||
WordPack.pack(scratch, 'hello world');
|
||||
const packed = Array.from(scratch.data.subarray(0, scratch.pos));
|
||||
|
||||
const r = new Capture(c.out, start);
|
||||
expect(r.g1()).toBe(ClientProt.MESSAGE_PRIVATE);
|
||||
expect(r.g1()).toBe(8 + packed.length); // size covers userhash + text
|
||||
expect(r.g8()).toBe(JString.toUserhash('botb'));
|
||||
expect(r.rest(c.out.pos)).toEqual(packed);
|
||||
});
|
||||
|
||||
test('sendPrivateMessage() self-echoes as a sent-PM (type 6) and rejects bad names', () => {
|
||||
if (!available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c = makeClient('bota');
|
||||
c.sendPrivateMessage('botb', 'hello world');
|
||||
|
||||
expect(c.chatType[0]).toBe(6); // same type the browser uses for "To <name>:"
|
||||
expect(c.chatUsername[0]).toBe('Botb');
|
||||
expect(c.chatText[0]).toBe('Hello world');
|
||||
|
||||
const before = c.out.pos;
|
||||
expect(c.sendPrivateMessage('', 'hi').ok).toBe(false);
|
||||
expect(c.out.pos).toBe(before); // nothing written for an invalid target
|
||||
});
|
||||
|
||||
test('INV_BUTTON carries the real obj id from linkObjType, not 0', () => {
|
||||
if (!available) {
|
||||
return;
|
||||
|
||||
@@ -790,6 +790,40 @@ export function say(c: LiteClient, message: string): SayOutcome {
|
||||
return { ok: true, truncated, filtered, finalText: echoed };
|
||||
}
|
||||
|
||||
export function sendPrivateMessage(c: LiteClient, targetName: string, message: string): SayOutcome {
|
||||
if (!c.isInGame() || !c.localPlayer) {
|
||||
return { ok: false, truncated: false, filtered: false, finalText: '' };
|
||||
}
|
||||
|
||||
const userhash = JString.toUserhash(targetName);
|
||||
if (userhash === 0n) {
|
||||
return { ok: false, truncated: false, filtered: false, finalText: '' };
|
||||
}
|
||||
|
||||
const cap = c.getMaxMessageLength();
|
||||
const truncated = message.length > cap;
|
||||
const text = truncated ? message.substring(0, cap) : message;
|
||||
|
||||
// MessagePrivateDecoder reads: 8-byte target userhash, then packed text -
|
||||
// the size byte covers both.
|
||||
c.writeOpcode(ClientProt.MESSAGE_PRIVATE);
|
||||
const lengthPos = c.out.pos;
|
||||
c.out.p1(0);
|
||||
const start = c.out.pos;
|
||||
c.out.p8(userhash);
|
||||
WordPack.pack(c.out, text);
|
||||
c.out.data[lengthPos] = c.out.pos - start;
|
||||
|
||||
// Local echo as a sent-PM (type 6), mirroring Client's socialInput path:
|
||||
// the server never echoes your own PM back.
|
||||
const cased = JString.toSentenceCase(text);
|
||||
const echoed = WordFilter.filter(cased);
|
||||
const filtered = echoed !== cased;
|
||||
c.addChat(6, echoed, JString.toScreenName(JString.toRawUsername(userhash)));
|
||||
|
||||
return { ok: true, truncated, filtered, finalText: echoed };
|
||||
}
|
||||
|
||||
// ================================================================ combat
|
||||
|
||||
export function setCombatStyle(c: LiteClient, style: number): boolean {
|
||||
|
||||
Reference in new issue
Block a user