Merge pull request #667 from rubyrunsstuff/fix/discord-forwarded-snapshots
Discord: include forwarded message snapshots
This commit is contained in:
@@ -27,6 +27,8 @@
|
|||||||
- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.
|
- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.
|
||||||
- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.
|
- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.
|
||||||
- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.
|
- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.
|
||||||
|
- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff.
|
||||||
|
- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff.
|
||||||
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
|
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
|
||||||
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
|
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
|
||||||
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
|
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ Telegram is the only provider with draft streaming:
|
|||||||
- `partial`: draft updates with the latest stream text.
|
- `partial`: draft updates with the latest stream text.
|
||||||
- `block`: draft updates in chunked blocks (same chunker rules).
|
- `block`: draft updates in chunked blocks (same chunker rules).
|
||||||
- `off`: no draft streaming.
|
- `off`: no draft streaming.
|
||||||
|
- Draft chunk config (only for `streamMode: "block"`): `telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`).
|
||||||
- Draft streaming is separate from block streaming; block replies are off by default and only enabled by `*.blockStreaming: true` on non-Telegram providers.
|
- Draft streaming is separate from block streaming; block replies are off by default and only enabled by `*.blockStreaming: true` on non-Telegram providers.
|
||||||
- Final reply is still a normal message.
|
- Final reply is still a normal message.
|
||||||
- `/reasoning stream` writes reasoning into the draft bubble (Telegram only).
|
- `/reasoning stream` writes reasoning into the draft bubble (Telegram only).
|
||||||
|
|||||||
@@ -676,6 +676,11 @@ Multi-account support lives under `telegram.accounts` (see the multi-account sec
|
|||||||
},
|
},
|
||||||
replyToMode: "first", // off | first | all
|
replyToMode: "first", // off | first | all
|
||||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||||
|
draftChunk: { // optional; only for streamMode=block
|
||||||
|
minChars: 200,
|
||||||
|
maxChars: 800,
|
||||||
|
breakPreference: "paragraph" // paragraph | newline | sentence
|
||||||
|
},
|
||||||
actions: { reactions: true, sendMessage: true }, // tool action gates (false disables)
|
actions: { reactions: true, sendMessage: true }, // tool action gates (false disables)
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
retry: { // outbound retry policy
|
retry: { // outbound retry policy
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ Config:
|
|||||||
- `partial`: update the draft bubble with the latest streaming text.
|
- `partial`: update the draft bubble with the latest streaming text.
|
||||||
- `block`: update the draft bubble in larger blocks (chunked).
|
- `block`: update the draft bubble in larger blocks (chunked).
|
||||||
- `off`: disable draft streaming.
|
- `off`: disable draft streaming.
|
||||||
|
- Optional (only for `streamMode: "block"`):
|
||||||
|
- `telegram.draftChunk: { minChars?, maxChars?, breakPreference? }`
|
||||||
|
- defaults: `minChars: 200`, `maxChars: 800`, `breakPreference: "paragraph"` (clamped to `telegram.textChunkLimit`).
|
||||||
|
|
||||||
Note: draft streaming is separate from **block streaming** (provider messages).
|
Note: draft streaming is separate from **block streaming** (provider messages).
|
||||||
Block streaming is off by default and requires `telegram.blockStreaming: true`
|
Block streaming is off by default and requires `telegram.blockStreaming: true`
|
||||||
|
|||||||
54
src/auto-reply/reply/block-streaming.test.ts
Normal file
54
src/auto-reply/reply/block-streaming.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { resolveTelegramDraftStreamingChunking } from "./block-streaming.js";
|
||||||
|
|
||||||
|
describe("resolveTelegramDraftStreamingChunking", () => {
|
||||||
|
it("uses smaller defaults than block streaming", () => {
|
||||||
|
const chunking = resolveTelegramDraftStreamingChunking(
|
||||||
|
undefined,
|
||||||
|
"default",
|
||||||
|
);
|
||||||
|
expect(chunking).toEqual({
|
||||||
|
minChars: 200,
|
||||||
|
maxChars: 800,
|
||||||
|
breakPreference: "paragraph",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to telegram.textChunkLimit", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
telegram: { allowFrom: ["*"], textChunkLimit: 150 },
|
||||||
|
};
|
||||||
|
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
|
||||||
|
expect(chunking).toEqual({
|
||||||
|
minChars: 150,
|
||||||
|
maxChars: 150,
|
||||||
|
breakPreference: "paragraph",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports per-account overrides", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
telegram: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
draftChunk: {
|
||||||
|
minChars: 10,
|
||||||
|
maxChars: 20,
|
||||||
|
breakPreference: "sentence",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
|
||||||
|
expect(chunking).toEqual({
|
||||||
|
minChars: 10,
|
||||||
|
maxChars: 20,
|
||||||
|
breakPreference: "sentence",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
|||||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||||
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
|
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
|
||||||
|
const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200;
|
||||||
|
const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800;
|
||||||
const PROVIDER_COALESCE_DEFAULTS: Partial<
|
const PROVIDER_COALESCE_DEFAULTS: Partial<
|
||||||
Record<TextChunkProvider, { minChars: number; idleMs: number }>
|
Record<TextChunkProvider, { minChars: number; idleMs: number }>
|
||||||
> = {
|
> = {
|
||||||
@@ -72,6 +74,39 @@ export function resolveBlockStreamingChunking(
|
|||||||
return { minChars, maxChars, breakPreference };
|
return { minChars, maxChars, breakPreference };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveTelegramDraftStreamingChunking(
|
||||||
|
cfg: ClawdbotConfig | undefined,
|
||||||
|
accountId?: string | null,
|
||||||
|
): {
|
||||||
|
minChars: number;
|
||||||
|
maxChars: number;
|
||||||
|
breakPreference: "paragraph" | "newline" | "sentence";
|
||||||
|
} {
|
||||||
|
const providerKey: TextChunkProvider = "telegram";
|
||||||
|
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId);
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
const draftCfg =
|
||||||
|
cfg?.telegram?.accounts?.[normalizedAccountId]?.draftChunk ??
|
||||||
|
cfg?.telegram?.draftChunk;
|
||||||
|
|
||||||
|
const maxRequested = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX),
|
||||||
|
);
|
||||||
|
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||||
|
const minRequested = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN),
|
||||||
|
);
|
||||||
|
const minChars = Math.min(minRequested, maxChars);
|
||||||
|
const breakPreference =
|
||||||
|
draftCfg?.breakPreference === "newline" ||
|
||||||
|
draftCfg?.breakPreference === "sentence"
|
||||||
|
? draftCfg.breakPreference
|
||||||
|
: "paragraph";
|
||||||
|
return { minChars, maxChars, breakPreference };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveBlockStreamingCoalescing(
|
export function resolveBlockStreamingCoalescing(
|
||||||
cfg: ClawdbotConfig | undefined,
|
cfg: ClawdbotConfig | undefined,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"telegram.botToken": "Telegram Bot Token",
|
"telegram.botToken": "Telegram Bot Token",
|
||||||
"telegram.dmPolicy": "Telegram DM Policy",
|
"telegram.dmPolicy": "Telegram DM Policy",
|
||||||
"telegram.streamMode": "Telegram Draft Stream Mode",
|
"telegram.streamMode": "Telegram Draft Stream Mode",
|
||||||
|
"telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars",
|
||||||
|
"telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars",
|
||||||
|
"telegram.draftChunk.breakPreference":
|
||||||
|
"Telegram Draft Chunk Break Preference",
|
||||||
"telegram.retry.attempts": "Telegram Retry Attempts",
|
"telegram.retry.attempts": "Telegram Retry Attempts",
|
||||||
"telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
"telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
||||||
"telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
"telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
||||||
@@ -203,6 +207,12 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].',
|
||||||
"telegram.streamMode":
|
"telegram.streamMode":
|
||||||
"Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.",
|
"Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.",
|
||||||
|
"telegram.draftChunk.minChars":
|
||||||
|
'Minimum chars before emitting a Telegram draft update when telegram.streamMode="block" (default: 200).',
|
||||||
|
"telegram.draftChunk.maxChars":
|
||||||
|
'Target max size for a Telegram draft update chunk when telegram.streamMode="block" (default: 800; clamped to telegram.textChunkLimit).',
|
||||||
|
"telegram.draftChunk.breakPreference":
|
||||||
|
"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.",
|
||||||
"telegram.retry.attempts":
|
"telegram.retry.attempts":
|
||||||
"Max retry attempts for outbound Telegram API calls (default: 3).",
|
"Max retry attempts for outbound Telegram API calls (default: 3).",
|
||||||
"telegram.retry.minDelayMs":
|
"telegram.retry.minDelayMs":
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export type BlockStreamingCoalesceConfig = {
|
|||||||
idleMs?: number;
|
idleMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BlockStreamingChunkConfig = {
|
||||||
|
minChars?: number;
|
||||||
|
maxChars?: number;
|
||||||
|
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||||
|
};
|
||||||
|
|
||||||
export type HumanDelayConfig = {
|
export type HumanDelayConfig = {
|
||||||
/** Delay style for block replies (off|natural|custom). */
|
/** Delay style for block replies (off|natural|custom). */
|
||||||
mode?: "off" | "natural" | "custom";
|
mode?: "off" | "natural" | "custom";
|
||||||
@@ -345,6 +351,8 @@ export type TelegramAccountConfig = {
|
|||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Chunking config for draft streaming in `streamMode: "block"`. */
|
||||||
|
draftChunk?: BlockStreamingChunkConfig;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
|
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
|
||||||
@@ -1318,11 +1326,7 @@ export type AgentDefaultsConfig = {
|
|||||||
*/
|
*/
|
||||||
blockStreamingBreak?: "text_end" | "message_end";
|
blockStreamingBreak?: "text_end" | "message_end";
|
||||||
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
|
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
|
||||||
blockStreamingChunk?: {
|
blockStreamingChunk?: BlockStreamingChunkConfig;
|
||||||
minChars?: number;
|
|
||||||
maxChars?: number;
|
|
||||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
|
||||||
};
|
|
||||||
/**
|
/**
|
||||||
* Block reply coalescing (merge streamed chunks before send).
|
* Block reply coalescing (merge streamed chunks before send).
|
||||||
* idleMs: wait time before flushing when idle.
|
* idleMs: wait time before flushing when idle.
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ const BlockStreamingCoalesceSchema = z.object({
|
|||||||
idleMs: z.number().int().nonnegative().optional(),
|
idleMs: z.number().int().nonnegative().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BlockStreamingChunkSchema = z.object({
|
||||||
|
minChars: z.number().int().positive().optional(),
|
||||||
|
maxChars: z.number().int().positive().optional(),
|
||||||
|
breakPreference: z
|
||||||
|
.union([
|
||||||
|
z.literal("paragraph"),
|
||||||
|
z.literal("newline"),
|
||||||
|
z.literal("sentence"),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const HumanDelaySchema = z.object({
|
const HumanDelaySchema = z.object({
|
||||||
mode: z
|
mode: z
|
||||||
.union([z.literal("off"), z.literal("natural"), z.literal("custom")])
|
.union([z.literal("off"), z.literal("natural"), z.literal("custom")])
|
||||||
@@ -207,6 +219,7 @@ const TelegramAccountSchemaBase = z.object({
|
|||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
draftChunk: BlockStreamingChunkSchema.optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
|
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
@@ -1038,19 +1051,7 @@ const AgentDefaultsSchema = z
|
|||||||
blockStreamingBreak: z
|
blockStreamingBreak: z
|
||||||
.union([z.literal("text_end"), z.literal("message_end")])
|
.union([z.literal("text_end"), z.literal("message_end")])
|
||||||
.optional(),
|
.optional(),
|
||||||
blockStreamingChunk: z
|
blockStreamingChunk: BlockStreamingChunkSchema.optional(),
|
||||||
.object({
|
|
||||||
minChars: z.number().int().positive().optional(),
|
|
||||||
maxChars: z.number().int().positive().optional(),
|
|
||||||
breakPreference: z
|
|
||||||
.union([
|
|
||||||
z.literal("paragraph"),
|
|
||||||
z.literal("newline"),
|
|
||||||
z.literal("sentence"),
|
|
||||||
])
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
humanDelay: HumanDelaySchema.optional(),
|
humanDelay: HumanDelaySchema.optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -193,6 +193,96 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes forwarded message snapshots in body", async () => {
|
||||||
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
|
let capturedBody = "";
|
||||||
|
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||||
|
capturedBody = ctx.Body ?? "";
|
||||||
|
dispatcher.sendFinalReply({ text: "ok" });
|
||||||
|
return { queuedFinal: true, counts: { final: 1 } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: "/tmp/clawd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||||
|
discord: { dm: { enabled: true, policy: "open" } },
|
||||||
|
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||||
|
|
||||||
|
const handler = createDiscordMessageHandler({
|
||||||
|
cfg,
|
||||||
|
discordConfig: cfg.discord,
|
||||||
|
accountId: "default",
|
||||||
|
token: "token",
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number): never => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
botUserId: "bot-id",
|
||||||
|
guildHistories: new Map(),
|
||||||
|
historyLimit: 0,
|
||||||
|
mediaMaxBytes: 10_000,
|
||||||
|
textLimit: 2000,
|
||||||
|
replyToMode: "off",
|
||||||
|
dmEnabled: true,
|
||||||
|
groupDmEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
fetchChannel: vi.fn().mockResolvedValue({
|
||||||
|
type: ChannelType.DM,
|
||||||
|
name: "dm",
|
||||||
|
}),
|
||||||
|
} as unknown as Client;
|
||||||
|
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
id: "m-forward-1",
|
||||||
|
content: "",
|
||||||
|
channelId: "c-forward-1",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: MessageType.Default,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentionedEveryone: false,
|
||||||
|
mentionedUsers: [],
|
||||||
|
mentionedRoles: [],
|
||||||
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
rawData: {
|
||||||
|
message_snapshots: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: "forwarded hello",
|
||||||
|
embeds: [],
|
||||||
|
attachments: [],
|
||||||
|
author: {
|
||||||
|
id: "u2",
|
||||||
|
username: "Bob",
|
||||||
|
discriminator: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
guild_id: null,
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedBody).toContain("[Forwarded message from @Bob]");
|
||||||
|
expect(capturedBody).toContain("forwarded hello");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses channel id allowlists for non-thread channels with categories", async () => {
|
it("uses channel id allowlists for non-thread channels with categories", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
let capturedCtx: { SessionKey?: string } | undefined;
|
let capturedCtx: { SessionKey?: string } | undefined;
|
||||||
|
|||||||
@@ -99,6 +99,25 @@ type DiscordMediaInfo = {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DiscordSnapshotAuthor = {
|
||||||
|
id?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
discriminator?: string | null;
|
||||||
|
global_name?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscordSnapshotMessage = {
|
||||||
|
content?: string | null;
|
||||||
|
embeds?: Array<{ description?: string | null; title?: string | null }> | null;
|
||||||
|
attachments?: APIAttachment[] | null;
|
||||||
|
author?: DiscordSnapshotAuthor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscordMessageSnapshot = {
|
||||||
|
message?: DiscordSnapshotMessage | null;
|
||||||
|
};
|
||||||
|
|
||||||
type DiscordHistoryEntry = {
|
type DiscordHistoryEntry = {
|
||||||
sender: string;
|
sender: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -706,7 +725,12 @@ export function createDiscordMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const botId = botUserId;
|
const botId = botUserId;
|
||||||
const baseText = resolveDiscordMessageText(message);
|
const baseText = resolveDiscordMessageText(message, {
|
||||||
|
includeForwarded: false,
|
||||||
|
});
|
||||||
|
const messageText = resolveDiscordMessageText(message, {
|
||||||
|
includeForwarded: true,
|
||||||
|
});
|
||||||
recordProviderActivity({
|
recordProviderActivity({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
accountId,
|
accountId,
|
||||||
@@ -732,7 +756,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
matchesMentionPatterns(baseText, mentionRegexes));
|
matchesMentionPatterns(baseText, mentionRegexes));
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`,
|
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,7 +884,9 @@ export function createDiscordMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textForHistory = resolveDiscordMessageText(message);
|
const textForHistory = resolveDiscordMessageText(message, {
|
||||||
|
includeForwarded: true,
|
||||||
|
});
|
||||||
if (isGuildMessage && historyLimit > 0 && textForHistory) {
|
if (isGuildMessage && historyLimit > 0 && textForHistory) {
|
||||||
const history = guildHistories.get(message.channelId) ?? [];
|
const history = guildHistories.get(message.channelId) ?? [];
|
||||||
history.push({
|
history.push({
|
||||||
@@ -957,7 +983,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
||||||
const text = baseText;
|
const text = messageText;
|
||||||
if (!text) {
|
if (!text) {
|
||||||
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
||||||
return;
|
return;
|
||||||
@@ -1938,17 +1964,86 @@ function buildDiscordAttachmentPlaceholder(
|
|||||||
|
|
||||||
function resolveDiscordMessageText(
|
function resolveDiscordMessageText(
|
||||||
message: Message,
|
message: Message,
|
||||||
fallbackText?: string,
|
options?: { fallbackText?: string; includeForwarded?: boolean },
|
||||||
): string {
|
): string {
|
||||||
return (
|
const baseText =
|
||||||
message.content?.trim() ||
|
message.content?.trim() ||
|
||||||
buildDiscordAttachmentPlaceholder(message.attachments) ||
|
buildDiscordAttachmentPlaceholder(message.attachments) ||
|
||||||
message.embeds?.[0]?.description ||
|
message.embeds?.[0]?.description ||
|
||||||
fallbackText?.trim() ||
|
options?.fallbackText?.trim() ||
|
||||||
""
|
"";
|
||||||
|
if (!options?.includeForwarded) return baseText;
|
||||||
|
const forwardedText = resolveDiscordForwardedMessagesText(message);
|
||||||
|
if (!forwardedText) return baseText;
|
||||||
|
if (!baseText) return forwardedText;
|
||||||
|
return `${baseText}\n${forwardedText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDiscordForwardedMessagesText(message: Message): string {
|
||||||
|
const snapshots = resolveDiscordMessageSnapshots(message);
|
||||||
|
if (snapshots.length === 0) return "";
|
||||||
|
const forwardedBlocks = snapshots
|
||||||
|
.map((snapshot) => {
|
||||||
|
const snapshotMessage = snapshot.message;
|
||||||
|
if (!snapshotMessage) return null;
|
||||||
|
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
|
||||||
|
if (!text) return null;
|
||||||
|
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
|
||||||
|
const heading = authorLabel
|
||||||
|
? `[Forwarded message from ${authorLabel}]`
|
||||||
|
: "[Forwarded message]";
|
||||||
|
return `${heading}\n${text}`;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
|
if (forwardedBlocks.length === 0) return "";
|
||||||
|
return forwardedBlocks.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDiscordMessageSnapshots(
|
||||||
|
message: Message,
|
||||||
|
): DiscordMessageSnapshot[] {
|
||||||
|
const rawData = (message as { rawData?: { message_snapshots?: unknown } })
|
||||||
|
.rawData;
|
||||||
|
const snapshots =
|
||||||
|
rawData?.message_snapshots ??
|
||||||
|
(message as { message_snapshots?: unknown }).message_snapshots ??
|
||||||
|
(message as { messageSnapshots?: unknown }).messageSnapshots;
|
||||||
|
if (!Array.isArray(snapshots)) return [];
|
||||||
|
return snapshots.filter(
|
||||||
|
(entry): entry is DiscordMessageSnapshot =>
|
||||||
|
Boolean(entry) && typeof entry === "object",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDiscordSnapshotMessageText(
|
||||||
|
snapshot: DiscordSnapshotMessage,
|
||||||
|
): string {
|
||||||
|
const content = snapshot.content?.trim() ?? "";
|
||||||
|
const attachmentText = buildDiscordAttachmentPlaceholder(
|
||||||
|
snapshot.attachments ?? undefined,
|
||||||
|
);
|
||||||
|
const embed = snapshot.embeds?.[0];
|
||||||
|
const embedText = embed?.description?.trim() || embed?.title?.trim() || "";
|
||||||
|
return content || attachmentText || embedText || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDiscordSnapshotAuthor(
|
||||||
|
author: DiscordSnapshotAuthor | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!author) return undefined;
|
||||||
|
const globalName = author.global_name ?? undefined;
|
||||||
|
const username = author.username ?? undefined;
|
||||||
|
const name = author.name ?? undefined;
|
||||||
|
const discriminator = author.discriminator ?? undefined;
|
||||||
|
const base = globalName || username || name;
|
||||||
|
if (username && discriminator && discriminator !== "0") {
|
||||||
|
return `@${username}#${discriminator}`;
|
||||||
|
}
|
||||||
|
if (base) return `@${base}`;
|
||||||
|
if (author.id) return `@${author.id}`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDiscordMediaPayload(
|
export function buildDiscordMediaPayload(
|
||||||
mediaList: Array<{ path: string; contentType?: string }>,
|
mediaList: Array<{ path: string; contentType?: string }>,
|
||||||
): {
|
): {
|
||||||
@@ -1977,7 +2072,9 @@ export function buildDiscordMediaPayload(
|
|||||||
function resolveReplyContext(message: Message): string | null {
|
function resolveReplyContext(message: Message): string | null {
|
||||||
const referenced = message.referencedMessage;
|
const referenced = message.referencedMessage;
|
||||||
if (!referenced?.author) return null;
|
if (!referenced?.author) return null;
|
||||||
const referencedText = resolveDiscordMessageText(referenced);
|
const referencedText = resolveDiscordMessageText(referenced, {
|
||||||
|
includeForwarded: true,
|
||||||
|
});
|
||||||
if (!referencedText) return null;
|
if (!referencedText) return null;
|
||||||
const fromLabel = referenced.author
|
const fromLabel = referenced.author
|
||||||
? buildDirectLabel(referenced.author)
|
? buildDirectLabel(referenced.author)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
listNativeCommandSpecs,
|
listNativeCommandSpecs,
|
||||||
} from "../auto-reply/commands-registry.js";
|
} from "../auto-reply/commands-registry.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { resolveBlockStreamingChunking } from "../auto-reply/reply/block-streaming.js";
|
import { resolveTelegramDraftStreamingChunking } from "../auto-reply/reply/block-streaming.js";
|
||||||
import {
|
import {
|
||||||
buildMentionRegexes,
|
buildMentionRegexes,
|
||||||
matchesMentionPatterns,
|
matchesMentionPatterns,
|
||||||
@@ -749,7 +749,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const draftChunking =
|
const draftChunking =
|
||||||
draftStream && streamMode === "block"
|
draftStream && streamMode === "block"
|
||||||
? resolveBlockStreamingChunking(cfg, "telegram", route.accountId)
|
? resolveTelegramDraftStreamingChunking(cfg, route.accountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const draftChunker = draftChunking
|
const draftChunker = draftChunking
|
||||||
? new EmbeddedBlockChunker(draftChunking)
|
? new EmbeddedBlockChunker(draftChunking)
|
||||||
|
|||||||
Reference in New Issue
Block a user