Add Kotlin plugin system (SDK, host, hooks, settings UI)

Implement exteraGram-style plugin architecture for Android and desktop:
- plugins/sdk: BasePlugin API, hook types, settings DSL
- plugins/host: PluginEngine, typed send hooks, FeatureGate, HookRegistry
- JVM ByteBuddy and Android Pine hook backends
- DevServer on port 42690 (JVM)
- Plugins settings screens and PluginUiHost bulletin overlay
- hello_world sample plugin packaged as .fcplugin
- ProGuard keepnames for ru.fromchat.** with shrinking enabled

Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Cursor Agent
2026-09-16 22:15:42 +00:00
Unverified
parent 926343e90e
commit b2ef7be57a
33 changed files with 1515 additions and 5 deletions
+62
View File
@@ -0,0 +1,62 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(project(":plugins:sdk"))
}
tasks.register("packageFcPlugin") {
group = "plugins"
description = "Build hello_world.fcplugin with desktop JAR"
dependsOn(tasks.named("compileKotlin"))
doLast {
val buildDir = layout.buildDirectory.get().asFile
val staging = buildDir.resolve("fcplugin-staging")
staging.deleteRecursively()
staging.mkdirs()
staging.resolve("desktop").mkdirs()
staging.resolve("android").mkdirs()
val jvmClasses = buildDir.resolve("classes/kotlin/main")
val jarFile = staging.resolve("desktop/plugin.jar")
val jarProcess = ProcessBuilder(
"jar",
"cf",
jarFile.absolutePath,
"-C",
jvmClasses.absolutePath,
".",
).inheritIO().start()
check(jarProcess.waitFor() == 0) { "jar failed" }
staging.resolve("manifest.json").writeText(
"""
{
"id": "hello_world",
"name": "Hello World",
"description": "Rewrites .hello commands into a friendly greeting",
"author": "FromChat",
"version": "1.0.0",
"app_version": ">=1.0.0",
"sdk_version": ">=1.0.0",
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
"artifacts": {
"android": "android/plugin.dex",
"desktop": "desktop/plugin.jar"
}
}
""".trimIndent(),
)
val archive = buildDir.resolve("distributions/hello_world.fcplugin")
archive.parentFile.mkdirs()
if (archive.exists()) archive.delete()
val zipProcess = ProcessBuilder("zip", "-r", archive.absolutePath, ".")
.directory(staging)
.inheritIO()
.start()
check(zipProcess.waitFor() == 0) { "zip failed" }
println("Created ${archive.absolutePath}")
}
}
@@ -0,0 +1,41 @@
package ru.fromchat.plugins.sample.hello
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.PluginSetting
import ru.fromchat.plugins.SendMessageHookContext
class HelloWorldPlugin : BasePlugin() {
override fun onPluginLoad() {
log("Hello World plugin loaded")
showBulletin("Hello World plugin is active")
addOnSendMessageHook { context ->
onSendMessage(context)
}
}
override fun createSettings(): List<PluginSetting> = listOf(
PluginSetting.Header("Hello World"),
PluginSetting.Input(
key = "template",
text = "Greeting template",
default = "Hello, {name}!",
subtext = "Use {name} for the entered name",
),
PluginSetting.Text(
text = "Send .hello Alice in a chat",
subtext = "Example: .hello Alice -> Hello, Alice!",
),
)
private fun onSendMessage(context: SendMessageHookContext): HookResult<SendMessageHookContext> {
val raw = context.text.trim()
if (!raw.startsWith(".hello")) return HookResult()
val parts = raw.split(" ", limit = 2)
val name = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { "World" }
val template = getSettingString("template", "Hello, {name}!")
context.text = template.replace("{name}", name)
return HookResult(strategy = HookStrategy.MODIFY, value = context)
}
}