Compare commits

...

2 Commits

Author SHA1 Message Date
rdmclin2 3930e6add3 chore: add bot message installId for systembot 2026-06-12 16:39:29 +08:00
lobehubbot 553d3d8fc7 🔖 chore(release): release version v2.2.3 [skip ci] 2026-06-10 11:38:19 +00:00
5 changed files with 187 additions and 91 deletions
+48 -24
View File
@@ -1,5 +1,5 @@
import { readFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
import path from 'node:path';
import { DEFAULT_BOT_HISTORY_LIMIT } from '@lobechat/const';
import type { Command } from 'commander';
@@ -35,7 +35,8 @@ const MIME_EXT_MAP: Record<string, string> = {
'.webp': 'image/webp',
};
const inferMime = (path: string): string | undefined => MIME_EXT_MAP[extname(path).toLowerCase()];
const inferMime = (filePath: string): string | undefined =>
MIME_EXT_MAP[path.extname(filePath).toLowerCase()];
const inferAttachmentType = (mimeType?: string): AttachmentInput['type'] => {
if (!mimeType) return 'file';
@@ -80,7 +81,7 @@ const parseAttachmentArg = async (raw: string): Promise<AttachmentInput> => {
return {
fetchUrl: raw,
mimeType,
name: basename(pathname) || undefined,
name: path.basename(pathname) || undefined,
type: inferAttachmentType(mimeType),
};
}
@@ -89,7 +90,7 @@ const parseAttachmentArg = async (raw: string): Promise<AttachmentInput> => {
return {
data: bytes.toString('base64'),
mimeType,
name: basename(raw),
name: path.basename(raw),
type: inferAttachmentType(mimeType),
};
};
@@ -224,8 +225,11 @@ export function registerBotMessageCommands(bot: Command) {
// ── read ────────────────────────────────────────────────
message
.command('read <botId>')
.description('Read messages from a channel')
.command('read <botIdOrAtKey>')
.description(
'Read messages from a channel. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'to read through a System Bot messenger installation (see `lh bot messengers list`).',
)
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
.option('--limit <n>', 'Max messages to fetch', String(DEFAULT_BOT_HISTORY_LIMIT))
.option('--before <messageId>', 'Read messages before this ID')
@@ -236,7 +240,7 @@ export function registerBotMessageCommands(bot: Command) {
.option('--json', 'Output JSON')
.action(
async (
botId: string,
botIdOrAtKey: string,
options: {
after?: string;
before?: string;
@@ -248,11 +252,12 @@ export function registerBotMessageCommands(bot: Command) {
target: string;
},
) => {
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.readMessages.query({
...target,
after: options.after,
before: options.before,
botId,
channelId: options.target,
cursor: options.cursor,
endTime: options.endTime,
@@ -343,8 +348,11 @@ export function registerBotMessageCommands(bot: Command) {
// ── search ──────────────────────────────────────────────
message
.command('search <botId>')
.description('Search messages in a channel')
.command('search <botIdOrAtKey>')
.description(
'Search messages in a channel. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'for a System Bot install.',
)
.requiredOption('--target <channelId>', 'Channel ID to search in')
.requiredOption('--query <text>', 'Search query')
.option('--author-id <id>', 'Filter by author ID')
@@ -352,7 +360,7 @@ export function registerBotMessageCommands(bot: Command) {
.option('--json', 'Output JSON')
.action(
async (
botId: string,
botIdOrAtKey: string,
options: {
authorId?: string;
json?: boolean;
@@ -361,10 +369,11 @@ export function registerBotMessageCommands(bot: Command) {
target: string;
},
) => {
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.searchMessages.query({
...target,
authorId: options.authorId,
botId,
channelId: options.target,
limit: options.limit ? Number.parseInt(options.limit, 10) : undefined,
query: options.query,
@@ -418,16 +427,23 @@ export function registerBotMessageCommands(bot: Command) {
// ── reactions ───────────────────────────────────────────
message
.command('reactions <botId>')
.description('List reactions on a message')
.command('reactions <botIdOrAtKey>')
.description(
'List reactions on a message. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'for a System Bot install.',
)
.requiredOption('--target <channelId>', 'Channel ID')
.requiredOption('--message-id <id>', 'Message ID')
.option('--json', 'Output JSON')
.action(
async (botId: string, options: { json?: boolean; messageId: string; target: string }) => {
async (
botIdOrAtKey: string,
options: { json?: boolean; messageId: string; target: string },
) => {
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.getReactions.query({
botId,
...target,
channelId: options.target,
messageId: options.messageId,
});
@@ -487,14 +503,18 @@ export function registerBotMessageCommands(bot: Command) {
// ── pins ────────────────────────────────────────────────
message
.command('pins <botId>')
.description('List pinned messages')
.command('pins <botIdOrAtKey>')
.description(
'List pinned messages. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'for a System Bot install.',
)
.requiredOption('--target <channelId>', 'Channel ID')
.option('--json', 'Output JSON')
.action(async (botId: string, options: { json?: boolean; target: string }) => {
.action(async (botIdOrAtKey: string, options: { json?: boolean; target: string }) => {
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.listPins.query({
botId,
...target,
channelId: options.target,
});
@@ -594,14 +614,18 @@ export function registerBotMessageCommands(bot: Command) {
);
thread
.command('list <botId>')
.description('List threads in a channel')
.command('list <botIdOrAtKey>')
.description(
'List threads in a channel. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'for a System Bot install.',
)
.requiredOption('--target <channelId>', 'Channel ID')
.option('--json', 'Output JSON')
.action(async (botId: string, options: { json?: boolean; target: string }) => {
.action(async (botIdOrAtKey: string, options: { json?: boolean; target: string }) => {
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.listThreads.query({
botId,
...target,
channelId: options.target,
});
+92 -61
View File
@@ -261,6 +261,16 @@ const resolveSendTarget = async (
});
};
/**
* Shared "exactly one of botId / messengerInstallationId" guard, reused by
* every read + send procedure that can target either a per-agent bot or a
* System Bot messenger install. Both sources resolve through
* {@link resolveSendTarget}.
*/
const exactlyOneTarget = (v: { botId?: string; messengerInstallationId?: string }): boolean =>
!!v.botId !== !!v.messengerInstallationId;
const ONE_TARGET_MESSAGE = { message: 'Provide exactly one of botId or messengerInstallationId' };
// ── Router ───────────────────────────────────────────────
export const botMessageRouter = router({
@@ -328,28 +338,28 @@ export const botMessageRouter = router({
readMessages: botMessageProcedure
.input(
z.object({
after: z
.string()
.optional()
.transform((v) => v || undefined),
before: z
.string()
.optional()
.transform((v) => v || undefined),
botId: z.string(),
channelId: z.string(),
cursor: z.string().optional(),
endTime: z.string().optional(),
limit: z.number().min(MIN_BOT_HISTORY_LIMIT).max(MAX_BOT_HISTORY_LIMIT).optional(),
startTime: z.string().optional(),
}),
z
.object({
after: z
.string()
.optional()
.transform((v) => v || undefined),
before: z
.string()
.optional()
.transform((v) => v || undefined),
botId: z.string().optional(),
channelId: z.string(),
cursor: z.string().optional(),
endTime: z.string().optional(),
limit: z.number().min(MIN_BOT_HISTORY_LIMIT).max(MAX_BOT_HISTORY_LIMIT).optional(),
messengerInstallationId: z.string().optional(),
startTime: z.string().optional(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform, settings } = await resolveBot(
ctx.agentBotProviderModel,
input.botId,
);
const { service, platform, settings } = await resolveSendTarget(ctx, input);
const defaultLimit = (settings.historyLimit as number) || DEFAULT_BOT_HISTORY_LIMIT;
return service.readMessages({
after: input.after,
@@ -401,16 +411,19 @@ export const botMessageRouter = router({
searchMessages: botMessageProcedure
.input(
z.object({
authorId: z.string().optional(),
botId: z.string(),
channelId: z.string(),
limit: z.number().min(MIN_BOT_HISTORY_LIMIT).max(MAX_BOT_HISTORY_LIMIT).optional(),
query: z.string(),
}),
z
.object({
authorId: z.string().optional(),
botId: z.string().optional(),
channelId: z.string(),
limit: z.number().min(MIN_BOT_HISTORY_LIMIT).max(MAX_BOT_HISTORY_LIMIT).optional(),
messengerInstallationId: z.string().optional(),
query: z.string(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform } = await resolveBot(ctx.agentBotProviderModel, input.botId);
const { service, platform } = await resolveSendTarget(ctx, input);
return service.searchMessages({
authorId: input.authorId,
channelId: input.channelId,
@@ -443,14 +456,17 @@ export const botMessageRouter = router({
getReactions: botMessageProcedure
.input(
z.object({
botId: z.string(),
channelId: z.string(),
messageId: z.string(),
}),
z
.object({
botId: z.string().optional(),
channelId: z.string(),
messageId: z.string(),
messengerInstallationId: z.string().optional(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform } = await resolveBot(ctx.agentBotProviderModel, input.botId);
const { service, platform } = await resolveSendTarget(ctx, input);
return service.getReactions({
channelId: input.channelId,
messageId: input.messageId,
@@ -496,13 +512,16 @@ export const botMessageRouter = router({
listPins: botMessageProcedure
.input(
z.object({
botId: z.string(),
channelId: z.string(),
}),
z
.object({
botId: z.string().optional(),
channelId: z.string(),
messengerInstallationId: z.string().optional(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform } = await resolveBot(ctx.agentBotProviderModel, input.botId);
const { service, platform } = await resolveSendTarget(ctx, input);
return service.listPins({
channelId: input.channelId,
platform,
@@ -513,13 +532,16 @@ export const botMessageRouter = router({
getChannelInfo: botMessageProcedure
.input(
z.object({
botId: z.string(),
channelId: z.string(),
}),
z
.object({
botId: z.string().optional(),
channelId: z.string(),
messengerInstallationId: z.string().optional(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform } = await resolveBot(ctx.agentBotProviderModel, input.botId);
const { service, platform } = await resolveSendTarget(ctx, input);
return service.getChannelInfo({
channelId: input.channelId,
platform,
@@ -528,14 +550,17 @@ export const botMessageRouter = router({
listChannels: botMessageProcedure
.input(
z.object({
botId: z.string(),
filter: z.string().optional(),
serverId: z.string().optional(),
}),
z
.object({
botId: z.string().optional(),
filter: z.string().optional(),
messengerInstallationId: z.string().optional(),
serverId: z.string().optional(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform } = await resolveBot(ctx.agentBotProviderModel, input.botId);
const { service, platform } = await resolveSendTarget(ctx, input);
return service.listChannels({
filter: input.filter,
platform,
@@ -547,14 +572,17 @@ export const botMessageRouter = router({
getMemberInfo: botMessageProcedure
.input(
z.object({
botId: z.string(),
memberId: z.string(),
serverId: z.string().optional(),
}),
z
.object({
botId: z.string().optional(),
memberId: z.string(),
messengerInstallationId: z.string().optional(),
serverId: z.string().optional(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform } = await resolveBot(ctx.agentBotProviderModel, input.botId);
const { service, platform } = await resolveSendTarget(ctx, input);
return service.getMemberInfo({
memberId: input.memberId,
platform,
@@ -587,13 +615,16 @@ export const botMessageRouter = router({
listThreads: botMessageProcedure
.input(
z.object({
botId: z.string(),
channelId: z.string(),
}),
z
.object({
botId: z.string().optional(),
channelId: z.string(),
messengerInstallationId: z.string().optional(),
})
.refine(exactlyOneTarget, ONE_TARGET_MESSAGE),
)
.query(async ({ input, ctx }) => {
const { service, platform } = await resolveBot(ctx.agentBotProviderModel, input.botId);
const { service, platform } = await resolveSendTarget(ctx, input);
return service.listThreads({
channelId: input.channelId,
platform,
@@ -130,8 +130,19 @@ export const messageRuntime: ServerRuntimeRegistration = {
const service = new MessageDispatcherService({
discord: async () => {
const { credentials } = await resolveCredentials(providerModel, 'discord');
return new DiscordMessageService(new DiscordApi(credentials.botToken));
// Per-agent provider takes precedence; fall back to the LobeHub System
// Bot (the global Discord bot in `system_bot_providers`) so an agent
// invoked *through* the System Bot — e.g. @mentioned in a guild channel
// with no per-agent bot of its own — can still read channel history and
// reply in that channel. Mirrors the telegram fallback below.
try {
const { credentials } = await resolveCredentials(providerModel, 'discord');
return new DiscordMessageService(new DiscordApi(credentials.botToken));
} catch (error) {
const systemConfig = await getMessengerDiscordConfig();
if (!systemConfig) throw error;
return new DiscordMessageService(new DiscordApi(systemConfig.botToken));
}
},
feishu: async () => {
const { applicationId, credentials } = await resolveCredentials(providerModel, 'feishu');
@@ -162,8 +173,37 @@ export const messageRuntime: ServerRuntimeRegistration = {
return new QQMessageService(new QQApiClient(applicationId, credentials.appSecret));
},
slack: async () => {
const { credentials } = await resolveCredentials(providerModel, 'slack');
return new SlackMessageService(new SlackApi(credentials.botToken));
// Per-agent provider takes precedence. Unlike Discord/Telegram (a single
// global System Bot token), Slack's System Bot is per-workspace OAuth —
// each install carries its own token in `messenger_installations`, and
// the tool-execution context does not carry the originating workspace.
// So we can only safely fall back when the user has exactly ONE active
// Slack install; with multiple we'd risk reading the wrong workspace, so
// we refuse and point at the CLI's explicit `messengerInstallationId`.
try {
const { credentials } = await resolveCredentials(providerModel, 'slack');
return new SlackMessageService(new SlackApi(credentials.botToken));
} catch (error) {
if (!context.serverDB || !context.userId) throw error;
const slackInstalls = (
await MessengerInstallationModel.listByInstallerUserId(
context.serverDB,
context.userId,
gateKeeper,
)
).filter((row) => row.platform === 'slack');
if (slackInstalls.length === 0) throw error;
if (slackInstalls.length > 1) {
throw new Error(
'Multiple Slack System Bot workspaces are installed; cannot pick one automatically. ' +
'Use the lobehub CLI with an explicit messengerInstallationId to target a workspace.',
{ cause: error },
);
}
const botToken = (slackInstalls[0].credentials as { botToken?: string }).botToken;
if (!botToken) throw error;
return new SlackMessageService(new SlackApi(botToken));
}
},
telegram: async () => {
// Per-agent provider takes precedence; fall back to the env-backed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.2.2",
"version": "2.2.3",
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",
@@ -144,13 +144,14 @@ For platforms with degradation rules, prefer URL-sourced \`image\` attachments w
<usage_guidelines>
- **Before any send (\`sendMessage\` / \`sendDirectMessage\` / \`replyToThread\`)** from the web UI, follow the two-step rule in \`<outbound_routing>\`: \`listBots\` first; if it has no entry for the target platform, fall back to \`listMessengers\`.
- When you are already inside a platform conversation (e.g. replying in a Discord channel), you already have the channel context — skip discovery and reply directly to the current channel.
- **Reading the current channel always works — just call \`readMessages\`.** When you're inside a platform conversation, read/context operations (\`readMessages\`, \`searchMessages\`, \`listThreads\`, …) resolve credentials automatically: the agent's per-agent bot if it has one, otherwise the LobeHub System Bot that delivered the message. Do NOT preemptively tell the user "this agent has no bot configured" or push them to \`createBot\` before trying — call \`readMessages\` first and only surface a setup hint if the call itself fails.
- **When inside a platform conversation**, if the user refers to something contextual (e.g. "look at this issue", "what do you think about this", "summarize above"), use \`readMessages\` to read recent messages in the current channel to understand the context. Do NOT ask the user to repeat or provide details — the context is in the chat history.
- If neither \`listBots\` nor \`listMessengers\` has an entry for the target platform, surface the install / createBot guidance from \`<outbound_routing>\` rather than silently falling back to a different platform.
- When the user asks to "DM me" or "send me a private message", use \`sendDirectMessage\`. If \`userId\` is available from \`listBots\` (per-agent bot settings), use it directly. If not, ask the user for their platform user ID.
- **Never ask the user for channel IDs.** Use \`listChannels\` to discover channels yourself. If \`serverId\` is available from \`listBots\`, use it directly. If not, ask the user for the server/guild ID.
- When the user references a channel by name (e.g. "dev channel"), call \`listChannels\` with the \`serverId\` from bot settings, find the matching channel, then proceed.
- \`readMessages\`: \`channelId\` and \`platform\` are **required**. All other parameters are **optional** — omit them when not needed. \`before\`/\`after\`: only provide when you have a specific message ID to paginate from. Do NOT pass empty strings — omit entirely. For quick context (e.g. "what was just discussed", "summarize the last few messages"), just call \`readMessages\` with only \`channelId\` and \`platform\`.
- **For large-volume requests** (e.g. "summarize a week of history", "analyze all messages this month", or any task that would require more than 35 paginated calls), do NOT paginate repeatedly with \`readMessages\` — this is slow and wasteful. Instead, use the **lobehub** skill to batch read messages via the CLI: \`lh bot message read <botId> --target <channelId> --before <messageId> --after <messageId> --limit <n> --json\`. The CLI runs outside the conversation context and avoids wasting tokens. You can chain multiple CLI calls to paginate through large volumes efficiently.
- **For large-volume requests** (e.g. "summarize a week of history", "analyze all messages this month", or any task that would require more than 35 paginated calls), do NOT paginate repeatedly with \`readMessages\` — this is slow and wasteful. Instead, use the **lobehub** skill to batch read messages via the CLI: \`lh bot message read <botIdOrAtKey> --target <channelId> --before <messageId> --after <messageId> --limit <n> --json\`. \`<botIdOrAtKey>\` is a per-agent bot id, or \`@<messenger-install-id>\` (from \`listMessengers\`) to read through the LobeHub System Bot. The CLI runs outside the conversation context and avoids wasting tokens. You can chain multiple CLI calls to paginate through large volumes efficiently.
- Reactions use unicode emoji (👍) or platform-specific format (Discord custom emoji).
</usage_guidelines>