refactor: centralize channel ui metadata
This commit is contained in:
@@ -426,24 +426,17 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelTitle(_ id: String) -> String {
|
private func resolveChannelTitle(_ id: String) -> String {
|
||||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
let label = self.store.resolveChannelLabel(id)
|
||||||
return label
|
if label != id { return label }
|
||||||
}
|
|
||||||
return id.prefix(1).uppercased() + id.dropFirst()
|
return id.prefix(1).uppercased() + id.dropFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||||
if let detail = self.store.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
|
return self.store.resolveChannelDetailLabel(id)
|
||||||
return detail
|
|
||||||
}
|
|
||||||
return self.resolveChannelTitle(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
if let symbol = self.store.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
|
return self.store.resolveChannelSystemImage(id)
|
||||||
return symbol
|
|
||||||
}
|
|
||||||
return "message"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||||
|
|||||||
@@ -153,11 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let application: AnyCodable?
|
let application: AnyCodable?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChannelUiMetaEntry: Codable {
|
||||||
|
let id: String
|
||||||
|
let label: String
|
||||||
|
let detailLabel: String
|
||||||
|
let systemImage: String?
|
||||||
|
}
|
||||||
|
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let channelOrder: [String]
|
let channelOrder: [String]
|
||||||
let channelLabels: [String: String]
|
let channelLabels: [String: String]
|
||||||
let channelDetailLabels: [String: String]? = nil
|
let channelDetailLabels: [String: String]? = nil
|
||||||
let channelSystemImages: [String: String]? = nil
|
let channelSystemImages: [String: String]? = nil
|
||||||
|
let channelMeta: [ChannelUiMetaEntry]? = nil
|
||||||
let channels: [String: AnyCodable]
|
let channels: [String: AnyCodable]
|
||||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||||
let channelDefaultAccountId: [String: String]
|
let channelDefaultAccountId: [String: String]
|
||||||
@@ -219,6 +227,47 @@ final class ChannelsStore {
|
|||||||
var configRoot: [String: Any] = [:]
|
var configRoot: [String: Any] = [:]
|
||||||
var configLoaded = false
|
var configLoaded = false
|
||||||
|
|
||||||
|
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
|
||||||
|
self.snapshot?.channelMeta?.first(where: { $0.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveChannelLabel(_ id: String) -> String {
|
||||||
|
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
|
||||||
|
return meta.label
|
||||||
|
}
|
||||||
|
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveChannelDetailLabel(_ id: String) -> String {
|
||||||
|
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
|
||||||
|
return meta.detailLabel
|
||||||
|
}
|
||||||
|
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
return self.resolveChannelLabel(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
|
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
return "message"
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedChannelIds() -> [String] {
|
||||||
|
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
|
||||||
|
return meta.map { $0.id }
|
||||||
|
}
|
||||||
|
return self.snapshot?.channelOrder ?? []
|
||||||
|
}
|
||||||
|
|
||||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ struct CronJobEditor: View {
|
|||||||
@State var postPrefix: String = "Cron"
|
@State var postPrefix: String = "Cron"
|
||||||
|
|
||||||
var channelOptions: [String] {
|
var channelOptions: [String] {
|
||||||
let snapshot = self.channelsStore.snapshot
|
let ordered = self.channelsStore.orderedChannelIds()
|
||||||
let ordered = snapshot?.channelOrder ?? []
|
|
||||||
var options = ["last"] + ordered
|
var options = ["last"] + ordered
|
||||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty, !options.contains(trimmed) {
|
if !trimmed.isEmpty, !options.contains(trimmed) {
|
||||||
@@ -68,7 +67,7 @@ struct CronJobEditor: View {
|
|||||||
|
|
||||||
func channelLabel(for id: String) -> String {
|
func channelLabel(for id: String) -> String {
|
||||||
if id == "last" { return "last" }
|
if id == "last" { return "last" }
|
||||||
return self.channelsStore.snapshot?.channelLabels[id] ?? id
|
return self.channelsStore.resolveChannelLabel(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -1326,6 +1326,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
public let channellabels: [String: AnyCodable]
|
public let channellabels: [String: AnyCodable]
|
||||||
public let channeldetaillabels: [String: AnyCodable]?
|
public let channeldetaillabels: [String: AnyCodable]?
|
||||||
public let channelsystemimages: [String: AnyCodable]?
|
public let channelsystemimages: [String: AnyCodable]?
|
||||||
|
public let channelmeta: [[String: AnyCodable]]?
|
||||||
public let channels: [String: AnyCodable]
|
public let channels: [String: AnyCodable]
|
||||||
public let channelaccounts: [String: AnyCodable]
|
public let channelaccounts: [String: AnyCodable]
|
||||||
public let channeldefaultaccountid: [String: AnyCodable]
|
public let channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1336,6 +1337,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
channellabels: [String: AnyCodable],
|
channellabels: [String: AnyCodable],
|
||||||
channeldetaillabels: [String: AnyCodable]?,
|
channeldetaillabels: [String: AnyCodable]?,
|
||||||
channelsystemimages: [String: AnyCodable]?,
|
channelsystemimages: [String: AnyCodable]?,
|
||||||
|
channelmeta: [[String: AnyCodable]]?,
|
||||||
channels: [String: AnyCodable],
|
channels: [String: AnyCodable],
|
||||||
channelaccounts: [String: AnyCodable],
|
channelaccounts: [String: AnyCodable],
|
||||||
channeldefaultaccountid: [String: AnyCodable]
|
channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1345,6 +1347,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
self.channellabels = channellabels
|
self.channellabels = channellabels
|
||||||
self.channeldetaillabels = channeldetaillabels
|
self.channeldetaillabels = channeldetaillabels
|
||||||
self.channelsystemimages = channelsystemimages
|
self.channelsystemimages = channelsystemimages
|
||||||
|
self.channelmeta = channelmeta
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.channelaccounts = channelaccounts
|
self.channelaccounts = channelaccounts
|
||||||
self.channeldefaultaccountid = channeldefaultaccountid
|
self.channeldefaultaccountid = channeldefaultaccountid
|
||||||
@@ -1355,6 +1358,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
case channellabels = "channelLabels"
|
case channellabels = "channelLabels"
|
||||||
case channeldetaillabels = "channelDetailLabels"
|
case channeldetaillabels = "channelDetailLabels"
|
||||||
case channelsystemimages = "channelSystemImages"
|
case channelsystemimages = "channelSystemImages"
|
||||||
|
case channelmeta = "channelMeta"
|
||||||
case channels
|
case channels
|
||||||
case channelaccounts = "channelAccounts"
|
case channelaccounts = "channelAccounts"
|
||||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
case channeldefaultaccountid = "channelDefaultAccountId"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
BLUEBUBBLES_ACTION_NAMES,
|
||||||
|
BLUEBUBBLES_ACTIONS,
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
readBooleanParam,
|
readBooleanParam,
|
||||||
@@ -49,19 +51,7 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Supported action names for BlueBubbles */
|
/** Supported action names for BlueBubbles */
|
||||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||||
"react",
|
|
||||||
"edit",
|
|
||||||
"unsend",
|
|
||||||
"reply",
|
|
||||||
"sendWithEffect",
|
|
||||||
"renameGroup",
|
|
||||||
"setGroupIcon",
|
|
||||||
"addParticipant",
|
|
||||||
"removeParticipant",
|
|
||||||
"leaveGroup",
|
|
||||||
"sendAttachment",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
@@ -69,19 +59,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
if (!account.enabled || !account.configured) return [];
|
if (!account.enabled || !account.configured) return [];
|
||||||
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
|
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
|
||||||
const actions = new Set<ChannelMessageActionName>();
|
const actions = new Set<ChannelMessageActionName>();
|
||||||
// Check if running on macOS 26+ (edit not supported)
|
|
||||||
const macOS26 = isMacOS26OrHigher(account.accountId);
|
const macOS26 = isMacOS26OrHigher(account.accountId);
|
||||||
if (gate("reactions")) actions.add("react");
|
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
||||||
if (gate("edit") && !macOS26) actions.add("edit");
|
const spec = BLUEBUBBLES_ACTIONS[action];
|
||||||
if (gate("unsend")) actions.add("unsend");
|
if (!spec?.gate) continue;
|
||||||
if (gate("reply")) actions.add("reply");
|
if (spec.unsupportedOnMacOS26 && macOS26) continue;
|
||||||
if (gate("sendWithEffect")) actions.add("sendWithEffect");
|
if (gate(spec.gate)) actions.add(action);
|
||||||
if (gate("renameGroup")) actions.add("renameGroup");
|
}
|
||||||
if (gate("setGroupIcon")) actions.add("setGroupIcon");
|
|
||||||
if (gate("addParticipant")) actions.add("addParticipant");
|
|
||||||
if (gate("removeParticipant")) actions.add("removeParticipant");
|
|
||||||
if (gate("leaveGroup")) actions.add("leaveGroup");
|
|
||||||
if (gate("sendAttachment")) actions.add("sendAttachment");
|
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||||
|
|||||||
@@ -928,8 +928,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
||||||
warnedCryptoMissingRooms.add(roomId);
|
warnedCryptoMissingRooms.add(roomId);
|
||||||
const warning =
|
const hint = core.system.formatNativeDependencyHint({
|
||||||
"matrix: encryption enabled but crypto is unavailable; install @matrix-org/matrix-sdk-crypto-nodejs and restart";
|
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
|
||||||
|
manager: "pnpm",
|
||||||
|
downloadCommand:
|
||||||
|
"node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
|
||||||
|
});
|
||||||
|
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
|
||||||
logger.warn({ roomId }, warning);
|
logger.warn({ roomId }, warning);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CHANNEL_MESSAGE_ACTION_NAMES,
|
CHANNEL_MESSAGE_ACTION_NAMES,
|
||||||
type ChannelMessageActionName,
|
type ChannelMessageActionName,
|
||||||
} from "../../channels/plugins/types.js";
|
} from "../../channels/plugins/types.js";
|
||||||
|
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
@@ -25,14 +26,6 @@ import type { AnyAgentTool } from "./common.js";
|
|||||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||||
|
|
||||||
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||||
const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>([
|
|
||||||
"renameGroup",
|
|
||||||
"setGroupIcon",
|
|
||||||
"addParticipant",
|
|
||||||
"removeParticipant",
|
|
||||||
"leaveGroup",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function buildRoutingSchema() {
|
function buildRoutingSchema() {
|
||||||
return {
|
return {
|
||||||
channel: Type.Optional(Type.String()),
|
channel: Type.Optional(Type.String()),
|
||||||
|
|||||||
29
src/channels/plugins/bluebubbles-actions.ts
Normal file
29
src/channels/plugins/bluebubbles-actions.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ChannelMessageActionName } from "./types.js";
|
||||||
|
|
||||||
|
export type BlueBubblesActionSpec = {
|
||||||
|
gate: string;
|
||||||
|
groupOnly?: boolean;
|
||||||
|
unsupportedOnMacOS26?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLUEBUBBLES_ACTIONS = {
|
||||||
|
react: { gate: "reactions" },
|
||||||
|
edit: { gate: "edit", unsupportedOnMacOS26: true },
|
||||||
|
unsend: { gate: "unsend" },
|
||||||
|
reply: { gate: "reply" },
|
||||||
|
sendWithEffect: { gate: "sendWithEffect" },
|
||||||
|
renameGroup: { gate: "renameGroup", groupOnly: true },
|
||||||
|
setGroupIcon: { gate: "setGroupIcon", groupOnly: true },
|
||||||
|
addParticipant: { gate: "addParticipant", groupOnly: true },
|
||||||
|
removeParticipant: { gate: "removeParticipant", groupOnly: true },
|
||||||
|
leaveGroup: { gate: "leaveGroup", groupOnly: true },
|
||||||
|
sendAttachment: { gate: "sendAttachment" },
|
||||||
|
} as const satisfies Partial<Record<ChannelMessageActionName, BlueBubblesActionSpec>>;
|
||||||
|
|
||||||
|
export const BLUEBUBBLES_ACTION_NAMES = Object.keys(
|
||||||
|
BLUEBUBBLES_ACTIONS,
|
||||||
|
) as ChannelMessageActionName[];
|
||||||
|
|
||||||
|
export const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>(
|
||||||
|
BLUEBUBBLES_ACTION_NAMES.filter((action) => BLUEBUBBLES_ACTIONS[action]?.groupOnly),
|
||||||
|
);
|
||||||
@@ -5,6 +5,22 @@ import type { PluginOrigin } from "../../plugins/types.js";
|
|||||||
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
|
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
|
||||||
import type { ChannelMeta } from "./types.js";
|
import type { ChannelMeta } from "./types.js";
|
||||||
|
|
||||||
|
export type ChannelUiMetaEntry = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detailLabel: string;
|
||||||
|
systemImage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelUiCatalog = {
|
||||||
|
entries: ChannelUiMetaEntry[];
|
||||||
|
order: string[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
detailLabels: Record<string, string>;
|
||||||
|
systemImages: Record<string, string>;
|
||||||
|
byId: Record<string, ChannelUiMetaEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChannelPluginCatalogEntry = {
|
export type ChannelPluginCatalogEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
meta: ChannelMeta;
|
meta: ChannelMeta;
|
||||||
@@ -116,6 +132,34 @@ function buildCatalogEntry(candidate: {
|
|||||||
return { id, meta, install };
|
return { id, meta, install };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildChannelUiCatalog(
|
||||||
|
plugins: Array<{ id: string; meta: ChannelMeta }>,
|
||||||
|
): ChannelUiCatalog {
|
||||||
|
const entries: ChannelUiMetaEntry[] = plugins.map((plugin) => {
|
||||||
|
const detailLabel = plugin.meta.detailLabel ?? plugin.meta.selectionLabel ?? plugin.meta.label;
|
||||||
|
return {
|
||||||
|
id: plugin.id,
|
||||||
|
label: plugin.meta.label,
|
||||||
|
detailLabel,
|
||||||
|
...(plugin.meta.systemImage ? { systemImage: plugin.meta.systemImage } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const order = entries.map((entry) => entry.id);
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
const detailLabels: Record<string, string> = {};
|
||||||
|
const systemImages: Record<string, string> = {};
|
||||||
|
const byId: Record<string, ChannelUiMetaEntry> = {};
|
||||||
|
for (const entry of entries) {
|
||||||
|
labels[entry.id] = entry.label;
|
||||||
|
detailLabels[entry.id] = entry.detailLabel;
|
||||||
|
if (entry.systemImage) {
|
||||||
|
systemImages[entry.id] = entry.systemImage;
|
||||||
|
}
|
||||||
|
byId[entry.id] = entry;
|
||||||
|
}
|
||||||
|
return { entries, order, labels, detailLabels, systemImages, byId };
|
||||||
|
}
|
||||||
|
|
||||||
export function listChannelPluginCatalogEntries(
|
export function listChannelPluginCatalogEntries(
|
||||||
options: CatalogOptions = {},
|
options: CatalogOptions = {},
|
||||||
): ChannelPluginCatalogEntry[] {
|
): ChannelPluginCatalogEntry[] {
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ export const ChannelAccountSnapshotSchema = Type.Object(
|
|||||||
{ additionalProperties: true },
|
{ additionalProperties: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ChannelUiMetaSchema = Type.Object(
|
||||||
|
{
|
||||||
|
id: NonEmptyString,
|
||||||
|
label: NonEmptyString,
|
||||||
|
detailLabel: NonEmptyString,
|
||||||
|
systemImage: Type.Optional(Type.String()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const ChannelsStatusResultSchema = Type.Object(
|
export const ChannelsStatusResultSchema = Type.Object(
|
||||||
{
|
{
|
||||||
ts: Type.Integer({ minimum: 0 }),
|
ts: Type.Integer({ minimum: 0 }),
|
||||||
@@ -62,6 +72,7 @@ export const ChannelsStatusResultSchema = Type.Object(
|
|||||||
channelLabels: Type.Record(NonEmptyString, NonEmptyString),
|
channelLabels: Type.Record(NonEmptyString, NonEmptyString),
|
||||||
channelDetailLabels: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
|
channelDetailLabels: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
|
||||||
channelSystemImages: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
|
channelSystemImages: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
|
||||||
|
channelMeta: Type.Optional(Type.Array(ChannelUiMetaSchema)),
|
||||||
channels: Type.Record(NonEmptyString, Type.Unknown()),
|
channels: Type.Record(NonEmptyString, Type.Unknown()),
|
||||||
channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)),
|
channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)),
|
||||||
channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),
|
channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
listChannelPlugins,
|
listChannelPlugins,
|
||||||
normalizeChannelId,
|
normalizeChannelId,
|
||||||
} from "../../channels/plugins/index.js";
|
} from "../../channels/plugins/index.js";
|
||||||
|
import { buildChannelUiCatalog } from "../../channels/plugins/catalog.js";
|
||||||
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
|
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
|
||||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
|
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
@@ -188,24 +189,14 @@ export const channelsHandlers: GatewayRequestHandlers = {
|
|||||||
return { accounts, defaultAccountId, defaultAccount, resolvedAccounts };
|
return { accounts, defaultAccountId, defaultAccount, resolvedAccounts };
|
||||||
};
|
};
|
||||||
|
|
||||||
const channelLabels = Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.meta.label]));
|
const uiCatalog = buildChannelUiCatalog(plugins);
|
||||||
const channelDetailLabels = Object.fromEntries(
|
|
||||||
plugins.map((plugin) => [
|
|
||||||
plugin.id,
|
|
||||||
plugin.meta.detailLabel ?? plugin.meta.selectionLabel ?? plugin.meta.label,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const channelSystemImages = Object.fromEntries(
|
|
||||||
plugins.flatMap((plugin) =>
|
|
||||||
plugin.meta.systemImage ? [[plugin.id, plugin.meta.systemImage]] : [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
channelOrder: plugins.map((plugin) => plugin.id),
|
channelOrder: uiCatalog.order,
|
||||||
channelLabels,
|
channelLabels: uiCatalog.labels,
|
||||||
channelDetailLabels,
|
channelDetailLabels: uiCatalog.detailLabels,
|
||||||
channelSystemImages,
|
channelSystemImages: uiCatalog.systemImages,
|
||||||
|
channelMeta: uiCatalog.entries,
|
||||||
channels: {} as Record<string, unknown>,
|
channels: {} as Record<string, unknown>,
|
||||||
channelAccounts: {} as Record<string, unknown>,
|
channelAccounts: {} as Record<string, unknown>,
|
||||||
channelDefaultAccountId: {} as Record<string, unknown>,
|
channelDefaultAccountId: {} as Record<string, unknown>,
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js";
|
export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js";
|
||||||
|
export {
|
||||||
|
BLUEBUBBLES_ACTIONS,
|
||||||
|
BLUEBUBBLES_ACTION_NAMES,
|
||||||
|
BLUEBUBBLES_GROUP_ACTIONS,
|
||||||
|
} from "../channels/plugins/bluebubbles-actions.js";
|
||||||
export type {
|
export type {
|
||||||
ChannelAccountSnapshot,
|
ChannelAccountSnapshot,
|
||||||
ChannelAccountState,
|
ChannelAccountState,
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ import { loginWeb } from "../../web/login.js";
|
|||||||
import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js";
|
import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js";
|
||||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
||||||
import { registerMemoryCli } from "../../cli/memory-cli.js";
|
import { registerMemoryCli } from "../../cli/memory-cli.js";
|
||||||
|
import { formatNativeDependencyHint } from "./native-deps.js";
|
||||||
|
|
||||||
import type { PluginRuntime } from "./types.js";
|
import type { PluginRuntime } from "./types.js";
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
system: {
|
system: {
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
runCommandWithTimeout,
|
runCommandWithTimeout,
|
||||||
|
formatNativeDependencyHint,
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
loadWebMedia,
|
loadWebMedia,
|
||||||
|
|||||||
28
src/plugins/runtime/native-deps.ts
Normal file
28
src/plugins/runtime/native-deps.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export type NativeDependencyHintParams = {
|
||||||
|
packageName: string;
|
||||||
|
manager?: "pnpm" | "npm" | "yarn";
|
||||||
|
rebuildCommand?: string;
|
||||||
|
approveBuildsCommand?: string;
|
||||||
|
downloadCommand?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatNativeDependencyHint(params: NativeDependencyHintParams): string {
|
||||||
|
const manager = params.manager ?? "pnpm";
|
||||||
|
const rebuildCommand =
|
||||||
|
params.rebuildCommand ??
|
||||||
|
(manager === "npm"
|
||||||
|
? `npm rebuild ${params.packageName}`
|
||||||
|
: manager === "yarn"
|
||||||
|
? `yarn rebuild ${params.packageName}`
|
||||||
|
: `pnpm rebuild ${params.packageName}`);
|
||||||
|
const approveBuildsCommand =
|
||||||
|
params.approveBuildsCommand ??
|
||||||
|
(manager === "pnpm" ? `pnpm approve-builds (select ${params.packageName})` : undefined);
|
||||||
|
const steps = [approveBuildsCommand, rebuildCommand, params.downloadCommand].filter(
|
||||||
|
(step): step is string => Boolean(step),
|
||||||
|
);
|
||||||
|
if (steps.length === 0) {
|
||||||
|
return `Install ${params.packageName} and rebuild its native module.`;
|
||||||
|
}
|
||||||
|
return `Install ${params.packageName} and rebuild its native module (${steps.join("; ")}).`;
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ type RecordChannelActivity = typeof import("../../infra/channel-activity.js").re
|
|||||||
type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity;
|
type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity;
|
||||||
type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent;
|
type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent;
|
||||||
type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout;
|
type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout;
|
||||||
|
type FormatNativeDependencyHint = typeof import("./native-deps.js").formatNativeDependencyHint;
|
||||||
type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia;
|
type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia;
|
||||||
type DetectMime = typeof import("../../media/mime.js").detectMime;
|
type DetectMime = typeof import("../../media/mime.js").detectMime;
|
||||||
type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFromMime;
|
type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFromMime;
|
||||||
@@ -146,6 +147,7 @@ export type PluginRuntime = {
|
|||||||
system: {
|
system: {
|
||||||
enqueueSystemEvent: EnqueueSystemEvent;
|
enqueueSystemEvent: EnqueueSystemEvent;
|
||||||
runCommandWithTimeout: RunCommandWithTimeout;
|
runCommandWithTimeout: RunCommandWithTimeout;
|
||||||
|
formatNativeDependencyHint: FormatNativeDependencyHint;
|
||||||
};
|
};
|
||||||
media: {
|
media: {
|
||||||
loadWebMedia: LoadWebMedia;
|
loadWebMedia: LoadWebMedia;
|
||||||
|
|||||||
@@ -272,8 +272,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
error: state.cronError,
|
error: state.cronError,
|
||||||
busy: state.cronBusy,
|
busy: state.cronBusy,
|
||||||
form: state.cronForm,
|
form: state.cronForm,
|
||||||
channels: state.channelsSnapshot?.channelOrder ?? [],
|
channels: state.channelsSnapshot?.channelMeta?.length
|
||||||
|
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||||
|
: state.channelsSnapshot?.channelOrder ?? [],
|
||||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||||
|
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||||
runsJobId: state.cronRunsJobId,
|
runsJobId: state.cronRunsJobId,
|
||||||
runs: state.cronRuns,
|
runs: state.cronRuns,
|
||||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ export type ChannelsStatusSnapshot = {
|
|||||||
channelLabels: Record<string, string>;
|
channelLabels: Record<string, string>;
|
||||||
channelDetailLabels?: Record<string, string>;
|
channelDetailLabels?: Record<string, string>;
|
||||||
channelSystemImages?: Record<string, string>;
|
channelSystemImages?: Record<string, string>;
|
||||||
|
channelMeta?: ChannelUiMetaEntry[];
|
||||||
channels: Record<string, unknown>;
|
channels: Record<string, unknown>;
|
||||||
channelAccounts: Record<string, ChannelAccountSnapshot[]>;
|
channelAccounts: Record<string, ChannelAccountSnapshot[]>;
|
||||||
channelDefaultAccountId: Record<string, string>;
|
channelDefaultAccountId: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChannelUiMetaEntry = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detailLabel: string;
|
||||||
|
systemImage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const CRON_CHANNEL_LAST = "last";
|
export const CRON_CHANNEL_LAST = "last";
|
||||||
|
|
||||||
export type ChannelAccountSnapshot = {
|
export type ChannelAccountSnapshot = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { html, nothing } from "lit";
|
|||||||
import { formatAgo } from "../format";
|
import { formatAgo } from "../format";
|
||||||
import type {
|
import type {
|
||||||
ChannelAccountSnapshot,
|
ChannelAccountSnapshot,
|
||||||
|
ChannelUiMetaEntry,
|
||||||
ChannelsStatusSnapshot,
|
ChannelsStatusSnapshot,
|
||||||
DiscordStatus,
|
DiscordStatus,
|
||||||
IMessageStatus,
|
IMessageStatus,
|
||||||
@@ -85,6 +86,9 @@ ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] {
|
function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] {
|
||||||
|
if (snapshot?.channelMeta?.length) {
|
||||||
|
return snapshot.channelMeta.map((entry) => entry.id) as ChannelKey[];
|
||||||
|
}
|
||||||
if (snapshot?.channelOrder?.length) {
|
if (snapshot?.channelOrder?.length) {
|
||||||
return snapshot.channelOrder;
|
return snapshot.channelOrder;
|
||||||
}
|
}
|
||||||
@@ -148,7 +152,7 @@ function renderGenericChannelCard(
|
|||||||
props: ChannelsProps,
|
props: ChannelsProps,
|
||||||
channelAccounts: Record<string, ChannelAccountSnapshot[]>,
|
channelAccounts: Record<string, ChannelAccountSnapshot[]>,
|
||||||
) {
|
) {
|
||||||
const label = props.snapshot?.channelLabels?.[key] ?? key;
|
const label = resolveChannelLabel(props.snapshot, key);
|
||||||
const status = props.snapshot?.channels?.[key] as Record<string, unknown> | undefined;
|
const status = props.snapshot?.channels?.[key] as Record<string, unknown> | undefined;
|
||||||
const configured = typeof status?.configured === "boolean" ? status.configured : undefined;
|
const configured = typeof status?.configured === "boolean" ? status.configured : undefined;
|
||||||
const running = typeof status?.running === "boolean" ? status.running : undefined;
|
const running = typeof status?.running === "boolean" ? status.running : undefined;
|
||||||
@@ -197,6 +201,21 @@ function renderGenericChannelCard(
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveChannelMetaMap(
|
||||||
|
snapshot: ChannelsStatusSnapshot | null,
|
||||||
|
): Record<string, ChannelUiMetaEntry> {
|
||||||
|
if (!snapshot?.channelMeta?.length) return {};
|
||||||
|
return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChannelLabel(
|
||||||
|
snapshot: ChannelsStatusSnapshot | null,
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
const meta = resolveChannelMetaMap(snapshot)[key];
|
||||||
|
return meta?.label ?? snapshot?.channelLabels?.[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
|
function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
formatCronState,
|
formatCronState,
|
||||||
formatNextRun,
|
formatNextRun,
|
||||||
} from "../presenter";
|
} from "../presenter";
|
||||||
import type { CronJob, CronRunLogEntry, CronStatus } from "../types";
|
import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types";
|
||||||
import type { CronFormState } from "../ui-types";
|
import type { CronFormState } from "../ui-types";
|
||||||
|
|
||||||
export type CronProps = {
|
export type CronProps = {
|
||||||
@@ -19,6 +19,7 @@ export type CronProps = {
|
|||||||
form: CronFormState;
|
form: CronFormState;
|
||||||
channels: string[];
|
channels: string[];
|
||||||
channelLabels?: Record<string, string>;
|
channelLabels?: Record<string, string>;
|
||||||
|
channelMeta?: ChannelUiMetaEntry[];
|
||||||
runsJobId: string | null;
|
runsJobId: string | null;
|
||||||
runs: CronRunLogEntry[];
|
runs: CronRunLogEntry[];
|
||||||
onFormChange: (patch: Partial<CronFormState>) => void;
|
onFormChange: (patch: Partial<CronFormState>) => void;
|
||||||
@@ -46,6 +47,8 @@ function buildChannelOptions(props: CronProps): string[] {
|
|||||||
|
|
||||||
function resolveChannelLabel(props: CronProps, channel: string): string {
|
function resolveChannelLabel(props: CronProps, channel: string): string {
|
||||||
if (channel === "last") return "last";
|
if (channel === "last") return "last";
|
||||||
|
const meta = props.channelMeta?.find((entry) => entry.id === channel);
|
||||||
|
if (meta?.label) return meta.label;
|
||||||
return props.channelLabels?.[channel] ?? channel;
|
return props.channelLabels?.[channel] ?? channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user