| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- package agi
- import (
- "fmt"
- "net/http"
- "sync"
- "sync/atomic"
- "time"
- "github.com/gorilla/websocket"
- "github.com/robertkrimen/otto"
- uuid "github.com/satori/go.uuid"
- "imuslab.com/arozos/mod/info/logger"
- user "imuslab.com/arozos/mod/user"
- )
- /*
- AJGI WebSocket Request Library
- This is a library for allowing AGI based connection upgrade to WebSocket.
- Different from other agi modules, this does not use the register lib interface
- due to its special nature.
- New functions exposed to AGI scripts:
- websocket.upgrade(timeoutSec)
- Upgrades the connection and overrides delay() with a message-pumping version
- so that websocket.onMessage fires naturally during pauses.
- websocket.send(text) --> bool
- websocket.read(timeoutMs?) --> string | null | false
- timeoutMs = 0 / omitted --> block until message arrives or connection closes
- timeoutMs > 0 --> return null on timeout (connection still open)
- returns false --> connection is closed
- websocket.available() --> int
- Number of messages currently waiting in the inbound buffer. Non-blocking.
- websocket.isClosed() --> bool
- true when the connection is no longer active.
- websocket.onMessage --> assign function(msg) to receive messages
- msg = { data: string, timestamp: int64 ms, type: int }
- Fired inside delay() on the script's own goroutine — Otto-safe.
- websocket.close()
- Author: tobychui
- */
- var upgrader = websocket.Upgrader{
- ReadBufferSize: 1024,
- WriteBufferSize: 1024,
- CheckOrigin: func(r *http.Request) bool {
- return true
- },
- }
- // wsMsg is a single inbound WebSocket frame delivered to the AGI script.
- type wsMsg struct {
- Data string // text payload
- Timestamp int64 // arrival time as unix milliseconds
- Type int // gorilla message type (1 = text, 2 = binary)
- }
- // wsConn wraps a gorilla websocket.Conn with a buffered inbound message channel.
- // All fields shared across goroutines are accessed via atomics or the channel itself.
- // The Otto VM is NEVER touched from any goroutine other than the main script goroutine.
- type wsConn struct {
- conn *websocket.Conn
- msgChan chan wsMsg // filled by the background reader goroutine
- closed int32 // 1 when closed; use atomic load/store
- lastOprTime int64 // unix seconds of last activity; use atomic load/store
- }
- func newWsConn(c *websocket.Conn) *wsConn {
- wsc := &wsConn{
- conn: c,
- msgChan: make(chan wsMsg, 128),
- }
- atomic.StoreInt64(&wsc.lastOprTime, time.Now().Unix())
- return wsc
- }
- func (w *wsConn) isClosed() bool { return atomic.LoadInt32(&w.closed) == 1 }
- func (w *wsConn) markClosed() { atomic.StoreInt32(&w.closed, 1) }
- func (w *wsConn) touchLastOpr() { atomic.StoreInt64(&w.lastOprTime, time.Now().Unix()) }
- func (w *wsConn) getLastOpr() int64 { return atomic.LoadInt64(&w.lastOprTime) }
- var connections = sync.Map{}
- // checkWebSocketConnectionUpgradeStatus returns whether the current VM has an
- // active WebSocket connection. Returns (active, connID, *wsConn).
- func checkWebSocketConnectionUpgradeStatus(vm *otto.Otto) (bool, string, *wsConn) {
- value, err := vm.Get("_websocket_conn_id")
- if err != nil || value.IsUndefined() || value.IsNull() {
- return false, "", nil
- }
- connId, err := value.ToString()
- if err != nil || connId == "" {
- return false, "", nil
- }
- raw, ok := connections.Load(connId)
- if !ok {
- return false, "", nil
- }
- wsc := raw.(*wsConn)
- if wsc.isClosed() {
- return false, connId, nil
- }
- return true, connId, wsc
- }
- // cleanupWsConn sends a close frame, closes the raw connection, and removes the
- // connection from both the sync.Map and the VM.
- // MUST be called from the main (script) goroutine so vm.Set is safe.
- func cleanupWsConn(vm *otto.Otto, connID string, wsc *wsConn) {
- if !wsc.isClosed() {
- wsc.markClosed()
- wsc.conn.WriteMessage(
- websocket.CloseMessage,
- websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
- )
- time.Sleep(150 * time.Millisecond)
- wsc.conn.Close()
- }
- vm.Set("_websocket_conn_id", otto.UndefinedValue())
- connections.Delete(connID)
- }
- // dispatchOnMessage calls websocket.onMessage(msg) on the script goroutine.
- // Passing data through vm.Set avoids the complexities of otto.Value.Call.
- // MUST be called from the main (script) goroutine.
- func dispatchOnMessage(vm *otto.Otto, msg wsMsg) {
- vm.Set("_ws_incoming", map[string]interface{}{
- "data": msg.Data,
- "timestamp": msg.Timestamp,
- "type": msg.Type,
- })
- if _, err := vm.Run(`
- if (typeof websocket !== 'undefined' && typeof websocket.onMessage === 'function') {
- websocket.onMessage(_ws_incoming);
- }
- `); err != nil {
- logger.PrintAndLog("Agi", fmt.Sprint("*AGI WebSocket* onMessage handler error:", err), nil)
- }
- vm.Set("_ws_incoming", otto.UndefinedValue())
- }
- func (g *Gateway) injectWebSocketFunctions(vm *otto.Otto, u *user.User, w http.ResponseWriter, r *http.Request) {
- // ── websocket.upgrade(timeoutSeconds) ────────────────────────────────────
- vm.Set("_websocket_upgrade", func(call otto.FunctionCall) otto.Value {
- timeout, err := call.Argument(0).ToInteger()
- if err != nil || timeout <= 0 {
- timeout = 300
- }
- if connState, _, _ := checkWebSocketConnectionUpgradeStatus(vm); connState {
- return otto.TrueValue() // already upgraded
- }
- c, err := upgrader.Upgrade(w, r, nil)
- if err != nil {
- logger.PrintAndLog("Agi", fmt.Sprint("*AGI WebSocket* upgrade failed:", err), nil)
- return otto.FalseValue()
- }
- wsc := newWsConn(c)
- connUUID := uuid.NewV4().String()
- connections.Store(connUUID, wsc)
- vm.Set("_websocket_conn_id", connUUID)
- // Background reader — feeds all inbound frames into msgChan.
- // Never touches the Otto VM; only updates wsc atomics and the channel.
- go func() {
- defer func() {
- wsc.markClosed()
- close(wsc.msgChan)
- // Do NOT call vm.Set here — Otto is not goroutine-safe.
- }()
- for {
- msgType, message, err := c.ReadMessage()
- if err != nil {
- return // connection closed or error
- }
- wsc.touchLastOpr()
- select {
- case wsc.msgChan <- wsMsg{
- Data: string(message),
- Timestamp: time.Now().UnixMilli(),
- Type: msgType,
- }:
- default:
- logger.PrintAndLog("Agi", "*AGI WebSocket* inbound buffer full, dropping frame", nil)
- }
- }
- }()
- // Idle-timeout watcher — closes the raw connection when no activity.
- // Does NOT touch the VM; closing the connection causes the reader to exit.
- go func() {
- ticker := time.NewTicker(1 * time.Second)
- defer ticker.Stop()
- for range ticker.C {
- if wsc.isClosed() {
- return
- }
- if time.Now().Unix()-wsc.getLastOpr() > timeout {
- logger.PrintAndLog("Agi", "*AGI WebSocket* idle timeout — closing connection", nil)
- c.Close()
- return
- }
- }
- }()
- // Override delay() so that websocket.onMessage callbacks fire naturally
- // inside pauses without the user needing an explicit poll() call.
- vm.Run(`
- var _ws_orig_delay = (typeof delay === 'function') ? delay : function(ms){};
- delay = function(ms){ _websocket_pump_messages(ms); };
- `)
- return otto.TrueValue()
- })
- // ── websocket.send(text) ─────────────────────────────────────────────────
- vm.Set("_websocket_send", func(call otto.FunctionCall) otto.Value {
- content, err := call.Argument(0).ToString()
- if err != nil {
- g.RaiseError(err)
- return otto.FalseValue()
- }
- connState, connID, wsc := checkWebSocketConnectionUpgradeStatus(vm)
- if !connState {
- return otto.FalseValue()
- }
- if err := wsc.conn.WriteMessage(websocket.TextMessage, []byte(content)); err != nil {
- cleanupWsConn(vm, connID, wsc)
- return otto.FalseValue()
- }
- wsc.touchLastOpr()
- return otto.TrueValue()
- })
- // ── websocket.read(timeout ms) ───────────────────────────────────────────
- // timeoutMs = 0 or omitted --> block until a message arrives or socket closes
- // timeoutMs > 0 --> return null if no message within that many ms
- // Returns: string on message · null on timeout · false if connection closed
- vm.Set("_websocket_read", func(call otto.FunctionCall) otto.Value {
- timeoutMs, _ := call.Argument(0).ToInteger()
- connState, connID, wsc := checkWebSocketConnectionUpgradeStatus(vm)
- if !connState {
- if connID != "" {
- // Stale entry — tidy up from the main goroutine
- vm.Set("_websocket_conn_id", otto.UndefinedValue())
- connections.Delete(connID)
- }
- return otto.FalseValue()
- }
- var msg wsMsg
- var ok bool
- if timeoutMs > 0 {
- select {
- case msg, ok = <-wsc.msgChan:
- case <-time.After(time.Duration(timeoutMs) * time.Millisecond):
- return otto.NullValue() // timed out; connection still open
- }
- } else {
- msg, ok = <-wsc.msgChan // block until message or channel close
- }
- if !ok {
- // Channel closed — background reader exited (connection gone)
- cleanupWsConn(vm, connID, wsc)
- return otto.FalseValue()
- }
- wsc.touchLastOpr()
- v, err := otto.ToValue(msg.Data)
- if err != nil {
- return otto.NullValue()
- }
- return v
- })
- // ── websocket.available() ────────────────────────────────────────────────
- // Returns the number of messages currently waiting in the inbound buffer.
- // Non-blocking — safe to poll in a tight loop.
- vm.Set("_websocket_available", func(call otto.FunctionCall) otto.Value {
- _, _, wsc := checkWebSocketConnectionUpgradeStatus(vm)
- count := 0
- if wsc != nil {
- count = len(wsc.msgChan)
- }
- v, _ := otto.ToValue(count)
- return v
- })
- // ── websocket.isClosed() ─────────────────────────────────────────────────
- // Returns true when the WebSocket connection is no longer active.
- vm.Set("_websocket_is_closed", func(call otto.FunctionCall) otto.Value {
- connState, _, _ := checkWebSocketConnectionUpgradeStatus(vm)
- if connState {
- return otto.FalseValue()
- }
- return otto.TrueValue()
- })
- // ── _websocket_pump_messages(ms) ─────────────────────────────────────────
- // Replaces delay() after upgrade. Sleeps for ms milliseconds while
- // dispatching any queued messages to websocket.onMessage.
- // All JS execution happens on this (main script) goroutine — Otto-safe.
- //
- // Important: messages are only consumed from the buffer when onMessage is
- // actually a function. When it is null/undefined the function falls back to
- // a plain sleep so that websocket.available() / websocket.read() can still
- // see the queued frames afterwards (Mode 2 / manual-read patterns).
- vm.Set("_websocket_pump_messages", func(call otto.FunctionCall) otto.Value {
- ms, err := call.Argument(0).ToInteger()
- if err != nil || ms < 0 {
- ms = 0
- }
- _, _, wsc := checkWebSocketConnectionUpgradeStatus(vm)
- if wsc == nil {
- // No active WebSocket — fall back to plain sleep
- if ms > 0 {
- time.Sleep(time.Duration(ms) * time.Millisecond)
- }
- return otto.UndefinedValue()
- }
- // Check once whether a handler is registered. We evaluate in JS so
- // that the typeof check is unambiguous regardless of Otto internals.
- hasHandlerVal, _ := vm.Run(`typeof websocket !== 'undefined' && typeof websocket.onMessage === 'function'`)
- hasHandler, _ := hasHandlerVal.ToBoolean()
- if !hasHandler {
- // No handler — plain sleep; leave messages in the buffer untouched.
- if ms > 0 {
- time.Sleep(time.Duration(ms) * time.Millisecond)
- }
- return otto.UndefinedValue()
- }
- const tickSize = 20 * time.Millisecond
- deadline := time.Now().Add(time.Duration(ms) * time.Millisecond)
- for {
- remaining := time.Until(deadline)
- if remaining <= 0 {
- break
- }
- tick := tickSize
- if remaining < tick {
- tick = remaining
- }
- select {
- case msg, ok := <-wsc.msgChan:
- if !ok {
- // Connection closed while waiting — stop pumping
- return otto.UndefinedValue()
- }
- wsc.touchLastOpr()
- dispatchOnMessage(vm, msg)
- case <-time.After(tick):
- // No message in this 20 ms slice — keep waiting
- }
- }
- return otto.UndefinedValue()
- })
- // ── websocket.close() ────────────────────────────────────────────────────
- vm.Set("_websocket_close", func(call otto.FunctionCall) otto.Value {
- connState, connID, wsc := checkWebSocketConnectionUpgradeStatus(vm)
- if !connState {
- return otto.FalseValue()
- }
- cleanupWsConn(vm, connID, wsc)
- return otto.TrueValue()
- })
- // ── JS wrapper ───────────────────────────────────────────────────────────
- vm.Run(`
- var websocket = {};
- websocket.upgrade = _websocket_upgrade;
- websocket.send = _websocket_send;
- websocket.read = _websocket_read;
- websocket.close = _websocket_close;
- websocket.available = _websocket_available;
- websocket.isClosed = _websocket_is_closed;
- // Assign a function(msg) here to receive messages asynchronously.
- // msg = { data: string, timestamp: number, type: number }
- // The handler fires inside delay() after websocket.upgrade() is called.
- websocket.onMessage = null;
- `)
- }
|